Compare commits
6 Commits
b3cfa280df
...
2026.03.03
| Author | SHA1 | Date | |
|---|---|---|---|
| 859c26f64c | |||
| 5e29ef63d4 | |||
| 079ff0a872 | |||
| 7084817dab | |||
| e16b92051d | |||
| b5e2de535e |
@@ -0,0 +1,36 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'EurKey-macOS.bundle/**'
|
||||
- 'scripts/**'
|
||||
- 'spec/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'EurKey-macOS.bundle/**'
|
||||
- 'scripts/**'
|
||||
- 'spec/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: validate layouts
|
||||
run: python3 scripts/validate_layouts.py
|
||||
|
||||
- name: build bundle
|
||||
run: bash scripts/build-bundle.sh
|
||||
|
||||
- name: create DMG
|
||||
run: bash scripts/create-dmg.sh
|
||||
|
||||
- name: upload DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: EurKEY-macOS-DMG
|
||||
path: build/*.dmg
|
||||
@@ -0,0 +1,82 @@
|
||||
# Sample workflow for building and deploying a Hugo site to GitHub Pages
|
||||
name: Deploy Hugo site to GitHub Pages
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
branches: ["master"]
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
# Default to bash
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
# Build job
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HUGO_VERSION: 0.120.4
|
||||
steps:
|
||||
- name: Install Hugo CLI
|
||||
run: |
|
||||
wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \
|
||||
&& sudo dpkg -i ${{ runner.temp }}/hugo.deb
|
||||
|
||||
- name: Install Dart Sass
|
||||
run: sudo snap install dart-sass
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true"
|
||||
|
||||
- name: Build with Hugo
|
||||
env:
|
||||
# For maximum backward compatibility with Hugo modules
|
||||
HUGO_ENVIRONMENT: production
|
||||
HUGO_ENV: production
|
||||
run: |
|
||||
hugo \
|
||||
--source /home/runner/work/EurKEY-macOS/EurKEY-macOS/eurkey-macos.eu/ \
|
||||
--minify \
|
||||
--baseURL "${{ steps.pages.outputs.base_url }}" # Adjust baseURL according to your directory structure
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: eurkey-macos.eu/public
|
||||
|
||||
# Deployment job
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: validate layouts
|
||||
run: python3 scripts/validate_layouts.py
|
||||
|
||||
- name: build bundle
|
||||
run: bash scripts/build-bundle.sh --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: create DMG
|
||||
run: bash scripts/create-dmg.sh --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: EurKEY-macOS ${{ steps.version.outputs.version }}
|
||||
files: build/EurKEY-macOS-${{ steps.version.outputs.version }}.dmg
|
||||
body: |
|
||||
## Installation
|
||||
|
||||
1. Download `EurKEY-macOS-${{ steps.version.outputs.version }}.dmg`
|
||||
2. Open the DMG
|
||||
3. Drag `EurKey-macOS.bundle` to the `Install Here (Keyboard Layouts)` folder
|
||||
4. Log out and back in (or restart)
|
||||
5. System Settings → Keyboard → Input Sources → Add → Select EurKEY
|
||||
|
||||
## Included layouts
|
||||
|
||||
- **EurKEY v1.2** — legacy version
|
||||
- **EurKEY v1.3** — official spec implementation
|
||||
- **EurKEY v1.4** — v1.3 with ẞ on Caps+§
|
||||
- **EurKEY v2.0** — custom edition
|
||||
generate_release_notes: true
|
||||
@@ -0,0 +1,31 @@
|
||||
**/public/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
build/
|
||||
*.dmg
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>de.felixfoertsch.keyboardlayout.EurKEY-macOS</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>EurKEY-macOS</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026.03.03</string>
|
||||
<key>KLInfo_EurKEY v1.2</key>
|
||||
<dict>
|
||||
<key>TICapsLockLanguageSwitchCapable</key>
|
||||
<false/>
|
||||
<key>TISIconIsTemplate</key>
|
||||
<true/>
|
||||
<key>TISInputSourceID</key>
|
||||
<string>de.felixfoertsch.keyboardlayout.EurKEY-macOS.eurkeyv1.2</string>
|
||||
<key>TISIntendedLanguage</key>
|
||||
<string>en</string>
|
||||
</dict>
|
||||
<key>KLInfo_EurKEY v1.3</key>
|
||||
<dict>
|
||||
<key>TICapsLockLanguageSwitchCapable</key>
|
||||
<false/>
|
||||
<key>TISIconIsTemplate</key>
|
||||
<true/>
|
||||
<key>TISInputSourceID</key>
|
||||
<string>de.felixfoertsch.keyboardlayout.EurKEY-macOS.eurkeyv1.3</string>
|
||||
<key>TISIntendedLanguage</key>
|
||||
<string>en</string>
|
||||
</dict>
|
||||
<key>KLInfo_EurKEY v1.4</key>
|
||||
<dict>
|
||||
<key>TICapsLockLanguageSwitchCapable</key>
|
||||
<false/>
|
||||
<key>TISIconIsTemplate</key>
|
||||
<true/>
|
||||
<key>TISInputSourceID</key>
|
||||
<string>de.felixfoertsch.keyboardlayout.EurKEY-macOS.eurkeyv1.4</string>
|
||||
<key>TISIntendedLanguage</key>
|
||||
<string>en</string>
|
||||
</dict>
|
||||
<key>KLInfo_EurKEY v2.0</key>
|
||||
<dict>
|
||||
<key>TICapsLockLanguageSwitchCapable</key>
|
||||
<false/>
|
||||
<key>TISIconIsTemplate</key>
|
||||
<true/>
|
||||
<key>TISInputSourceID</key>
|
||||
<string>de.felixfoertsch.keyboardlayout.EurKEY-macOS.eurkeyv2.0</string>
|
||||
<key>TISIntendedLanguage</key>
|
||||
<string>en</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildVersion</key>
|
||||
<string>2026.03.03</string>
|
||||
<key>ProjectName</key>
|
||||
<string>EurKEY-macOS</string>
|
||||
<key>SourceVersion</key>
|
||||
<string>2026.03.03</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,23 +1,118 @@
|
||||
EurKEY-Mac
|
||||
==========
|
||||
# EurKEY-macOS
|
||||
|
||||
The Keyboard Layout for Europeans, Coders and Translators - Version for Mac OS X.
|
||||
The keyboard layout for Europeans, coders, and translators. This repo contains a **modified version** of the EurKEY base layout for macOS, bundling multiple versions so users can pick what they need.
|
||||
|
||||
This is a port of the [EurKEY Keyboard layout](http://eurkey.steffen.bruentjen.eu/), which features a QWERTY baseline layout (=good access to braces etc.) with quick access to commonly used accented characters and Umlauts.
|
||||
EurKEY-macOS targets MacBooks with the physical English International keyboard (ISO) instead of the ANSI layout from the official upstream. Since it is an ISO layout, it has one additional key (`` ` ``) and the big Enter key.
|
||||
|
||||
**Status**: The whole layout should be mapped now. Please report if you find any missing characters.
|
||||
The keyboard layout should be compatible with the other ISO layouts typically available in Europe (e.g., German ISO). However, the printed keys will obviously be different. I tested the layout on the current tenkeyless MacBook keyboard (MacBook Air 2024). Working numpad keys are therefore not guaranteed.
|
||||
|
||||
## Versions
|
||||
|
||||
Install
|
||||
=======
|
||||
The bundle ships 4 layout versions:
|
||||
|
||||
Copy the two files `EurKEY.keylayout` and `EurKEY.icns` to your library, either system-wide (`/Library/Keyboard Layouts`) or for your local user (`~/Library/Keyboard Layouts`).
|
||||
| Version | Description |
|
||||
| ------- | ----------- |
|
||||
| **v1.3** | Official EurKEY spec implementation. **Recommended for most users.** |
|
||||
| **v1.2** | Legacy version based on [Leonardo Schenkel's port](https://github.com/lbschenkel/EurKEY-Mac). Predates the v1.3 spec (no `¬` dead key, `§` instead of `ẞ` on Shift+Option+S). |
|
||||
| **v1.4** | v1.3 with `ẞ` (capital sharp s) on Caps+`§` key. |
|
||||
| **v2.0** | Custom edition — complete rework. Every key configures exactly as printed on the MacBook keyboard. Removes left/right modifier key distinction. New monochrome template icon. |
|
||||
|
||||
## Installation
|
||||
|
||||
License
|
||||
=======
|
||||
### From DMG (recommended)
|
||||
|
||||
The Layout itself is licensed under [GPLv3](http://www.gnu.org/licenses/gpl-3.0.html).
|
||||
The EU flag icon is taken from [Iconspedia](http://www.iconspedia.com/pack/european-flags-1631/),
|
||||
created by [Alpak](http://alpak.deviantart.com/) and
|
||||
licensed under [CC](http://creativecommons.org/licenses/by-nc-nd/3.0)
|
||||
1. Download the latest `EurKEY-macOS-YYYY.MM.DD.dmg` from [Releases](https://github.com/felixfoertsch/EurKEY-macOS/releases).
|
||||
2. Open the DMG.
|
||||
3. Drag `EurKey-macOS.bundle` to the `Install Here (Keyboard Layouts)` folder.
|
||||
4. Log out and back in (or restart).
|
||||
5. System Settings → Keyboard → Input Sources → click `+` → select the EurKEY version you want.
|
||||
|
||||
### Manual
|
||||
|
||||
1. Download or clone this repo.
|
||||
2. Copy `EurKey-macOS.bundle` to `/Library/Keyboard Layouts/` (system-wide) or `~/Library/Keyboard Layouts/` (user-only).
|
||||
3. Log out and back in.
|
||||
4. System Settings → Keyboard → Input Sources → click `+` → select EurKEY.
|
||||
|
||||
<img src="eurkey-macos.eu/static/img/1-input-sources.png" width="300" alt="System preferences showing the edit button for input sources.">
|
||||
<img src="eurkey-macos.eu/static/img/2-add-layout.png" width="300" alt="Dialogue to add a new input source.">
|
||||
<img src="eurkey-macos.eu/static/img/3-select-eurkey.png" width="300" alt="EurKEY in the input sources list.">
|
||||
<img src="eurkey-macos.eu/static/img/4-select-input-method.png" width="300" alt="Selecting EurKEY from the menu bar dropdown.">
|
||||
|
||||
## Validation
|
||||
|
||||
The project includes automated validation to catch regressions. The validation script parses each `.keylayout` XML file and compares key mappings and dead key compositions against the v1.3 reference.
|
||||
|
||||
```bash
|
||||
# validate all layouts
|
||||
python3 scripts/validate_layouts.py
|
||||
|
||||
# parse a single layout to JSON
|
||||
python3 scripts/parse_keylayout.py "EurKey-macOS.bundle/Contents/Resources/EurKEY v1.3.keylayout" --summary
|
||||
|
||||
# build the bundle (validates + generates Info.plist)
|
||||
bash scripts/build-bundle.sh
|
||||
|
||||
# create a DMG installer
|
||||
bash scripts/create-dmg.sh
|
||||
```
|
||||
|
||||
## Dead key compositions (v2.0)
|
||||
|
||||
v2.0 renames all dead key states to their initializing key combination:
|
||||
|
||||
| Key combination | Dead key symbol |
|
||||
| --------------- | --------------- |
|
||||
| ⌥`` ` `` | `` ` `` |
|
||||
| ⌥⇧`` ` `` | ~ |
|
||||
| ⌥' | ´ |
|
||||
| ⌥⇧' | ¨ |
|
||||
| ⌥6 | ^ |
|
||||
| ⌥⇧6 | ˇ |
|
||||
| ⌥7 | ˚ |
|
||||
| ⌥⇧7 | ¯ |
|
||||
| ⌥m | Ω |
|
||||
| ⌥⇧m | √ |
|
||||
| ⌥\ | ¬ |
|
||||
|
||||
## Notes on Ukelele and template icons
|
||||
|
||||
Template icons switch color with the system theme (dark/light). Ukelele's GUI checkbox for template icons does not save correctly — the `TISIconIsTemplate` flag must be set manually in `Info.plist`:
|
||||
|
||||
```xml
|
||||
<key>TISIconIsTemplate</key>
|
||||
<true/>
|
||||
```
|
||||
|
||||
The build script (`scripts/build-bundle.sh`) generates `Info.plist` with this flag set correctly for all layout versions.
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2.0 (WIP)
|
||||
|
||||
- Configures every key exactly as it is printed on the MacBook keyboard (ISO, English International).
|
||||
- Removes distinction between left/right modifier keys.
|
||||
- Uses the `*.bundle` format to group the layout versions.
|
||||
- Adds new monochrome macOS template icon that switches color with the system theme.
|
||||
- Renames all dead key states to their initializing key combination for easier identification.
|
||||
|
||||
### v1.4
|
||||
|
||||
- Adds `ẞ` (capital sharp s) on Caps+`§` key.
|
||||
|
||||
### v1.3
|
||||
|
||||
- Implements the layout according to [spec](https://eurkey.steffen.bruentjen.eu/changelog.html). Based on [Leonardo Schenkel's version 1.2](https://github.com/lbschenkel/EurKEY-Mac).
|
||||
|
||||
### v1.2
|
||||
|
||||
- Original macOS port by [Leonardo Brondani Schenkel](https://github.com/lbschenkel/EurKEY-Mac).
|
||||
|
||||
## Attribution
|
||||
|
||||
The original EurKEY layout is by [Steffen Brüntjen](https://eurkey.steffen.bruentjen.eu/start.html). The macOS port is originally based on the work of [Leonardo Brondani Schenkel](https://github.com/lbschenkel/EurKEY-Mac).
|
||||
|
||||
## License
|
||||
|
||||
- The EurKEY Layout is licensed under [GPLv3](http://www.gnu.org/licenses/gpl-3.0.html). See: [eurkey.steffen.bruentjen.eu/license.html](https://eurkey.steffen.bruentjen.eu/license.html).
|
||||
- The EU flag icon is from [Iconspedia](http://www.iconspedia.com/pack/european-flags-1631/), created by [Alpak](http://alpak.deviantart.com/) and licensed under [CC BY-NC-ND 3.0](http://creativecommons.org/licenses/by-nc-nd/3.0).
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
+++
|
||||
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||
date = {{ .Date }}
|
||||
draft = true
|
||||
+++
|
||||
@@ -0,0 +1,58 @@
|
||||
# EurKEY-macOS
|
||||
|
||||
The Keyboard Layout for Europeans, Coders and Translators. This repo is a fork and contains a **modified verison** of the EurKEY base layout.
|
||||
|
||||
I start versioning my customized edition from 2, since the layout is based on my modified EurKEY v1.4 that I have been using the last few years. The version with slight fixes is now available is called v1.5.
|
||||
|
||||
EurKEY-macOS is a rework targeted at MacBooks with the with pyhiscal English International keyboard (ISO). Since it is an ISO layout, it has one additional key (`) and the big Enter key.
|
||||
|
||||
The keyboard layout should be compatible with the other ISO layouts typically available in Europe (e.g. German ISO). I tested the layout on the current tenkeyless MacBook keyboard (MacBook Air 2024). Working numpad keys are therefore not guaranteed.
|
||||
|
||||
## Installation
|
||||
|
||||
- Download the `EurKEY.bundle` file.
|
||||
- Copy the `EurKEY.bundle` file to `/Library/Keyboard Layouts/` (for global installation) or `~/Library/Keyboard Layouts/` (for user installation).
|
||||
- Open System Settings > Keyboard > Input Sources <br><img src="eurkey-macos.eu/static/img/1-input-sources.png" width="300">
|
||||
- Click the `+` button <br><img src="eurkey-macos.eu/static/img/2-add-layout.png" width="300">
|
||||
- Add `EurKEY` from the list of available input sources <br><img src="eurkey-macos.eu/static/img/3-select-eurkey.png" width="300">
|
||||
- Select `EurKEY` as the input method <br><img src="eurkey-macos.eu/static/img/4-select-input-method.png" width="300">
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2.0 (WIP)
|
||||
|
||||
| Key Combinations | Dead Key Symbol |
|
||||
| ---------------- | --------------- |
|
||||
| ⌥` | ` |
|
||||
| ⌥⇧` | ~ |
|
||||
| ⌥' | ´ |
|
||||
| ⌥⇧' | ¨ |
|
||||
| ⌥6 | ^ |
|
||||
| ⌥⇧6 | ˇ |
|
||||
| ⌥7 | ˚ |
|
||||
| ⌥⇧7 | ¯ |
|
||||
| ⌥m | Ω |
|
||||
| ⌥⇧m | √ |
|
||||
| ⌥\ | ¬ |
|
||||
|
||||
### v1.5
|
||||
|
||||
- Configures every key exactly as it is printed on the keyboard (English - International).
|
||||
- Fixes §-Key.
|
||||
- Fixes German ẞ-Character ("Großes scharfes S"). Now correctly available via ⌥⇧s.
|
||||
- Removes distiction between left/right modifier keys.
|
||||
- Uses the `*.bundle` format to group the layout versions.
|
||||
- Adds new nicer flag icon from upstream.
|
||||
|
||||
### v1.4
|
||||
|
||||
- Switches behaviour of superscript and subscript numbers: The subscript numbers are the default; the superscript numbers are available via `⌥⇧<number>`.
|
||||
|
||||
## Attribution
|
||||
|
||||
You can find the original EurKEY layout on [Steffen Brüntjens Website](https://eurkey.steffen.bruentjen.eu/start.html). My modified versions are originally based on the work of [Leonardo Brondani Schenkel](https://github.com/lbschenkel/EurKEY-Mac).
|
||||
|
||||
## License
|
||||
|
||||
- The EurKEY Layout is licensed under [GPLv3](http://www.gnu.org/licenses/gpl-3.0.html). See: [https://eurkey.steffen.bruentjen.eu/license.html](https://eurkey.steffen.bruentjen.eu/license.html).
|
||||
- The EU flag icon is taken from [Iconspedia](http://www.iconspedia.com/pack/european-flags-1631/), created by [Alpak](http://alpak.deviantart.com/) and licensed under [CC](http://creativecommons.org/licenses/by-nc-nd/3.0).
|
||||
@@ -0,0 +1,4 @@
|
||||
baseURL = 'https://eurkey-macos.eu'
|
||||
languageCode = 'en'
|
||||
title = 'EurKEY-macOS'
|
||||
theme = 'blank'
|
||||
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Vimux
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Blank
|
||||
|
||||
Blank — starter [Hugo](https://gohugo.io/) theme for developers. Use it to make your own theme.
|
||||
|
||||
**[Demo](https://blank-demo.netlify.app/)**
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
In your Hugo site `themes` directory, run:
|
||||
|
||||
```
|
||||
git clone https://github.com/vimux/blank
|
||||
```
|
||||
|
||||
Next, open `config.toml` in the base of the Hugo site and ensure the theme option is set to `blank`.
|
||||
|
||||
```
|
||||
theme = "blank"
|
||||
```
|
||||
|
||||
For more information read the official [quick start guide](https://gohugo.io/getting-started/quick-start/) of Hugo.
|
||||
|
||||
## Contributing
|
||||
|
||||
Have you found a bug or got an idea for a new feature? Feel free to use the [issue tracker](https://github.com/Vimux/blank/issues) to let me know. Or make directly a [pull request](https://github.com/Vimux/blank/pulls).
|
||||
|
||||
## License
|
||||
|
||||
This theme is released under the [MIT license](https://github.com/Vimux/blank/blob/master/LICENSE).
|
||||
@@ -0,0 +1,4 @@
|
||||
+++
|
||||
title = "{{ replace .Name "-" " " | title }}"
|
||||
date = {{ .Date }}
|
||||
+++
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 136 KiB |
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ .Site.LanguageCode | default "en-us" }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ .Title }}</title>
|
||||
{{ with .Site.Params.description }}<meta name="description" content="{{ . }}">{{ end }}
|
||||
{{ with .Site.Params.author }}<meta name="author" content="{{ . }}">{{ end }}
|
||||
<link rel="stylesheet" href="{{ "css/style.css" | relURL }}">
|
||||
{{ with .OutputFormats.Get "RSS" -}}
|
||||
{{ printf `<link rel="%s" type="%s" href="%s" title="%s">` .Rel .MediaType.Type .RelPermalink $.Site.Title | safeHTML }}
|
||||
{{- end }}
|
||||
</head>
|
||||
<body>
|
||||
{{ partial "header" . }}
|
||||
{{ block "main" . }}{{ end }}
|
||||
{{ partial "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{{ define "main" }}
|
||||
<main>
|
||||
{{ if or .Title .Content }}
|
||||
<div>
|
||||
{{ with .Title }}<h1>{{ . }}</h1>{{ end }}
|
||||
{{ with .Content }}<div>{{ . }}</div>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ range .Paginator.Pages }}
|
||||
{{ .Render "summary" }}
|
||||
{{ end }}
|
||||
{{ partial "pagination.html" . }}
|
||||
</main>
|
||||
{{ partial "sidebar.html" . }}
|
||||
{{ end }}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{ define "main" }}
|
||||
<main>
|
||||
<article>
|
||||
<h1>{{ .Title }}</h1>
|
||||
<time datetime="{{ .Date.Format "2006-01-02T15:04:05" }}">{{ .Date.Format "02.01.2006 15:04" }}</time>
|
||||
<div>
|
||||
{{ .Content }}
|
||||
</div>
|
||||
{{ with .Params.tags }}
|
||||
<div>
|
||||
<ul id="tags">
|
||||
{{ range . }}
|
||||
<li><a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ with .Site.DisqusShortname }}
|
||||
<div>
|
||||
{{ template "_internal/disqus.html" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</article>
|
||||
</main>
|
||||
{{ partial "sidebar.html" . }}
|
||||
{{ end }}
|
||||
@@ -0,0 +1,13 @@
|
||||
<article>
|
||||
<h1><a href="{{ .Permalink }}">{{ .Title }}</a></h1>
|
||||
<time datetime="{{ .Date.Format "2006-01-02T15:04:05" }}">{{ .Date.Format "02.01.2006 15:04" }}</time>
|
||||
{{ range .Params.tags }}
|
||||
<a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a>
|
||||
{{ end }}
|
||||
<div>
|
||||
{{ .Summary }}
|
||||
{{ if .Truncated }}
|
||||
<a href="{{ .Permalink }}">Read more...</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,7 @@
|
||||
{{ define "main" }} {{ readFile "content/README.md" | markdownify }}
|
||||
<main>
|
||||
{{ $paginator := .Paginate (where .Site.RegularPages "Type" "in"
|
||||
.Site.Params.mainSections) }} {{ range $paginator.Pages }} {{ .Render
|
||||
"summary" }} {{ end }} {{ partial "pagination.html" . }}
|
||||
</main>
|
||||
{{ partial "sidebar.html" . }} {{ end }}
|
||||
@@ -0,0 +1,3 @@
|
||||
<footer>
|
||||
<p>Last Update: {{ now.Format "2006-01-02" }}</p>
|
||||
</footer>
|
||||
@@ -0,0 +1,12 @@
|
||||
<header>
|
||||
<h1><a href="{{ .Site.BaseURL }}">{{ .Site.Title }}</a></h1>
|
||||
{{ with .Site.Menus.main }}
|
||||
<nav>
|
||||
<ul>
|
||||
{{ range . }}
|
||||
<li><a href="{{ .URL | relURL }}">{{ .Name }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</nav>
|
||||
{{ end }}
|
||||
</header>
|
||||
@@ -0,0 +1,9 @@
|
||||
<div>
|
||||
{{ if .Paginator.HasPrev }}
|
||||
<a href="{{ .Paginator.Prev.URL }}">Previous Page</a>
|
||||
{{ end }}
|
||||
{{ .Paginator.PageNumber }} of {{ .Paginator.TotalPages }}
|
||||
{{ if .Paginator.HasNext }}
|
||||
<a href="{{ .Paginator.Next.URL }}">Next Page</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<aside>
|
||||
<div>
|
||||
<div>
|
||||
<h3>LATEST POSTS</h3>
|
||||
</div>
|
||||
<div>
|
||||
<ul>
|
||||
{{ range first 5 (where .Site.RegularPages "Type" "in" .Site.Params.mainSections) }}
|
||||
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,13 @@
|
||||
name = "Blank"
|
||||
license = "MIT"
|
||||
licenselink = "https://github.com/vimux/blank/blob/master/LICENSE"
|
||||
description = "Starter Hugo theme for developers."
|
||||
homepage = "https://github.com/vimux/blank/"
|
||||
demosite = "https://blank-demo.netlify.app/"
|
||||
tags = ["blog", "plain", "blank", "starter", "development"]
|
||||
features = ["blog"]
|
||||
min_version = "0.20"
|
||||
|
||||
[author]
|
||||
name = "Vimux"
|
||||
homepage = "https://github.com/vimux"
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and validate the EurKEY-macOS keyboard layout bundle.
|
||||
#
|
||||
# Regenerates Info.plist with correct KLInfo entries for all layout versions,
|
||||
# sets the bundle version, and validates the bundle structure.
|
||||
#
|
||||
# Usage: bash scripts/build-bundle.sh [--version YYYY.MM.DD]
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
BUNDLE_DIR="${PROJECT_DIR}/EurKey-macOS.bundle"
|
||||
CONTENTS_DIR="${BUNDLE_DIR}/Contents"
|
||||
RESOURCES_DIR="${CONTENTS_DIR}/Resources"
|
||||
|
||||
# parse arguments
|
||||
VERSION="${1:-$(date +%Y.%m.%d)}"
|
||||
if [[ "${1:-}" == "--version" ]]; then
|
||||
VERSION="${2:?missing version argument}"
|
||||
fi
|
||||
|
||||
BUNDLE_ID="de.felixfoertsch.keyboardlayout.EurKEY-macOS"
|
||||
BUNDLE_NAME="EurKEY-macOS"
|
||||
|
||||
# layout versions to include
|
||||
VERSIONS=("v1.2" "v1.3" "v1.4" "v2.0")
|
||||
|
||||
echo "Building ${BUNDLE_NAME} ${VERSION}"
|
||||
echo "Bundle: ${BUNDLE_DIR}"
|
||||
echo
|
||||
|
||||
# --- validate that all required files exist ---
|
||||
errors=0
|
||||
for ver in "${VERSIONS[@]}"; do
|
||||
keylayout="${RESOURCES_DIR}/EurKEY ${ver}.keylayout"
|
||||
icns="${RESOURCES_DIR}/EurKEY ${ver}.icns"
|
||||
if [[ ! -f "${keylayout}" ]]; then
|
||||
echo "ERROR: missing ${keylayout}"
|
||||
errors=$((errors + 1))
|
||||
fi
|
||||
if [[ ! -f "${icns}" ]]; then
|
||||
echo "ERROR: missing ${icns}"
|
||||
errors=$((errors + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
for lang in en de es; do
|
||||
strings="${RESOURCES_DIR}/${lang}.lproj/InfoPlist.strings"
|
||||
if [[ ! -f "${strings}" ]]; then
|
||||
echo "ERROR: missing ${strings}"
|
||||
errors=$((errors + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $errors -gt 0 ]]; then
|
||||
echo "FAILED: ${errors} missing file(s)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- generate Info.plist ---
|
||||
cat > "${CONTENTS_DIR}/Info.plist" << 'PLIST_HEADER'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
PLIST_HEADER
|
||||
|
||||
echo " <string>${BUNDLE_ID}</string>" >> "${CONTENTS_DIR}/Info.plist"
|
||||
|
||||
cat >> "${CONTENTS_DIR}/Info.plist" << 'PLIST_NAME'
|
||||
<key>CFBundleName</key>
|
||||
PLIST_NAME
|
||||
|
||||
echo " <string>${BUNDLE_NAME}</string>" >> "${CONTENTS_DIR}/Info.plist"
|
||||
|
||||
cat >> "${CONTENTS_DIR}/Info.plist" << 'PLIST_VERSION'
|
||||
<key>CFBundleVersion</key>
|
||||
PLIST_VERSION
|
||||
|
||||
echo " <string>${VERSION}</string>" >> "${CONTENTS_DIR}/Info.plist"
|
||||
|
||||
# add KLInfo for each version
|
||||
for ver in "${VERSIONS[@]}"; do
|
||||
layout_name="EurKEY ${ver}"
|
||||
# generate input source ID: bundle id + layout name with spaces removed, lowercased
|
||||
source_id_suffix=$(echo "eurkey${ver}" | tr '[:upper:]' '[:lower:]' | tr -d ' ')
|
||||
source_id="${BUNDLE_ID}.${source_id_suffix}"
|
||||
|
||||
cat >> "${CONTENTS_DIR}/Info.plist" << KLINFO_ENTRY
|
||||
<key>KLInfo_${layout_name}</key>
|
||||
<dict>
|
||||
<key>TICapsLockLanguageSwitchCapable</key>
|
||||
<false/>
|
||||
<key>TISIconIsTemplate</key>
|
||||
<true/>
|
||||
<key>TISInputSourceID</key>
|
||||
<string>${source_id}</string>
|
||||
<key>TISIntendedLanguage</key>
|
||||
<string>en</string>
|
||||
</dict>
|
||||
KLINFO_ENTRY
|
||||
done
|
||||
|
||||
cat >> "${CONTENTS_DIR}/Info.plist" << 'PLIST_FOOTER'
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST_FOOTER
|
||||
|
||||
echo "Generated Info.plist with ${#VERSIONS[@]} layout entries"
|
||||
|
||||
# --- generate version.plist ---
|
||||
cat > "${CONTENTS_DIR}/version.plist" << VPLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildVersion</key>
|
||||
<string>${VERSION}</string>
|
||||
<key>ProjectName</key>
|
||||
<string>${BUNDLE_NAME}</string>
|
||||
<key>SourceVersion</key>
|
||||
<string>${VERSION}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
VPLIST
|
||||
|
||||
echo "Generated version.plist (${VERSION})"
|
||||
|
||||
# --- validate with plutil ---
|
||||
if command -v plutil &> /dev/null; then
|
||||
plutil -lint "${CONTENTS_DIR}/Info.plist" || exit 1
|
||||
plutil -lint "${CONTENTS_DIR}/version.plist" || exit 1
|
||||
echo "plist validation passed"
|
||||
fi
|
||||
|
||||
# --- run layout validation ---
|
||||
if [[ -f "${SCRIPT_DIR}/validate_layouts.py" ]]; then
|
||||
echo
|
||||
python3 "${SCRIPT_DIR}/validate_layouts.py" || exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Bundle build complete: ${BUNDLE_DIR}"
|
||||
echo "Version: ${VERSION}"
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a .dmg installer for EurKEY-macOS keyboard layouts.
|
||||
#
|
||||
# The DMG contains the keyboard layout bundle and a symlink to
|
||||
# /Library/Keyboard Layouts/ for drag-and-drop installation.
|
||||
#
|
||||
# Usage: bash scripts/create-dmg.sh [--version YYYY.MM.DD]
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
BUNDLE_DIR="${PROJECT_DIR}/EurKey-macOS.bundle"
|
||||
BUILD_DIR="${PROJECT_DIR}/build"
|
||||
|
||||
# parse arguments
|
||||
VERSION="${1:-$(date +%Y.%m.%d)}"
|
||||
if [[ "${1:-}" == "--version" ]]; then
|
||||
VERSION="${2:?missing version argument}"
|
||||
fi
|
||||
|
||||
DMG_NAME="EurKEY-macOS-${VERSION}"
|
||||
DMG_PATH="${BUILD_DIR}/${DMG_NAME}.dmg"
|
||||
STAGING_DIR="${BUILD_DIR}/dmg-staging"
|
||||
|
||||
echo "Creating DMG: ${DMG_NAME}"
|
||||
|
||||
# --- ensure bundle is built ---
|
||||
if [[ ! -f "${BUNDLE_DIR}/Contents/Info.plist" ]]; then
|
||||
echo "ERROR: bundle not found at ${BUNDLE_DIR}"
|
||||
echo "Run scripts/build-bundle.sh first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- prepare staging directory ---
|
||||
rm -rf "${STAGING_DIR}"
|
||||
mkdir -p "${STAGING_DIR}"
|
||||
|
||||
# copy the bundle
|
||||
cp -R "${BUNDLE_DIR}" "${STAGING_DIR}/"
|
||||
|
||||
# create symlink to installation target
|
||||
ln -s "/Library/Keyboard Layouts" "${STAGING_DIR}/Install Here (Keyboard Layouts)"
|
||||
|
||||
echo "Staged files:"
|
||||
ls -la "${STAGING_DIR}/"
|
||||
|
||||
# --- create DMG ---
|
||||
mkdir -p "${BUILD_DIR}"
|
||||
rm -f "${DMG_PATH}"
|
||||
|
||||
hdiutil create \
|
||||
-volname "${DMG_NAME}" \
|
||||
-srcfolder "${STAGING_DIR}" \
|
||||
-ov \
|
||||
-format UDZO \
|
||||
"${DMG_PATH}"
|
||||
|
||||
# --- clean up ---
|
||||
rm -rf "${STAGING_DIR}"
|
||||
|
||||
echo
|
||||
echo "DMG created: ${DMG_PATH}"
|
||||
echo "Size: $(du -h "${DMG_PATH}" | cut -f1)"
|
||||
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse Apple .keylayout XML files into a flat JSON representation.
|
||||
|
||||
Extracts all key mappings across modifier layers (base, Shift, Caps, Option,
|
||||
Shift+Option, Caps+Option, Command+Option) and resolves dead key states to
|
||||
their composed outputs.
|
||||
|
||||
Usage:
|
||||
python3 scripts/parse_keylayout.py <file.keylayout> [--output file.json]
|
||||
|
||||
Output JSON structure:
|
||||
{
|
||||
"name": "EurKEY v1.3",
|
||||
"modifierMap": { ... },
|
||||
"keyMaps": {
|
||||
"0": { "label": "Base", "keys": { "0": {"output": "a", ...}, ... } },
|
||||
...
|
||||
},
|
||||
"actions": {
|
||||
"a": {
|
||||
"none": "a",
|
||||
"dead: ^": "â",
|
||||
...
|
||||
},
|
||||
...
|
||||
},
|
||||
"deadKeys": {
|
||||
"dead: ^": { "terminator": "^", "compositions": { "a": "â", "A": "Â", ... } },
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
# macOS key code → physical key name (US ANSI/ISO layout)
|
||||
KEY_CODE_NAMES = {
|
||||
0: "A", 1: "S", 2: "D", 3: "F", 4: "H", 5: "G",
|
||||
6: "Z", 7: "X", 8: "C", 9: "V", 10: "§/`",
|
||||
11: "B", 12: "Q", 13: "W", 14: "E", 15: "R",
|
||||
16: "Y", 17: "T", 18: "1", 19: "2", 20: "3",
|
||||
21: "4", 22: "6", 23: "5", 24: "=", 25: "9",
|
||||
26: "7", 27: "-", 28: "8", 29: "0", 30: "]",
|
||||
31: "O", 32: "U", 33: "[", 34: "I", 35: "P",
|
||||
36: "Return", 37: "L", 38: "J", 39: "'", 40: "K",
|
||||
41: ";", 42: "\\", 43: ",", 44: "/", 45: "N",
|
||||
46: "M", 47: ".", 48: "Tab", 49: "Space", 50: "`",
|
||||
51: "Delete", 52: "Enter", 53: "Escape",
|
||||
# numpad
|
||||
65: "KP.", 67: "KP*", 69: "KP+", 75: "KP/",
|
||||
76: "KPEnter", 78: "KP-", 81: "KP=",
|
||||
82: "KP0", 83: "KP1", 84: "KP2", 85: "KP3",
|
||||
86: "KP4", 87: "KP5", 88: "KP6", 89: "KP7",
|
||||
91: "KP8", 92: "KP9",
|
||||
# iso extra key
|
||||
93: "ISO§", 94: "ISO_backslash", 95: "ISO_comma",
|
||||
# function/navigation keys
|
||||
96: "F5", 97: "F6", 98: "F7", 99: "F3",
|
||||
100: "F8", 101: "F9", 103: "F11", 105: "F13",
|
||||
107: "F14", 109: "F10", 111: "F12", 113: "F15",
|
||||
114: "Help/Insert", 115: "Home", 116: "PageUp",
|
||||
117: "ForwardDelete", 118: "F4", 119: "End",
|
||||
120: "F2", 121: "PageDown", 122: "F1",
|
||||
123: "Left", 124: "Right", 125: "Down", 126: "Up",
|
||||
}
|
||||
|
||||
# modifier map index → human-readable label
|
||||
MODIFIER_LABELS = {
|
||||
0: "Base",
|
||||
1: "Shift",
|
||||
2: "Caps",
|
||||
3: "Option",
|
||||
4: "Shift+Option",
|
||||
5: "Caps+Option",
|
||||
6: "Command+Option",
|
||||
7: "Control",
|
||||
}
|
||||
|
||||
# key codes that are "typing" keys (not function/navigation/control)
|
||||
TYPING_KEY_CODES = set(range(0, 50)) | {50, 93, 94, 95}
|
||||
|
||||
|
||||
def _read_keylayout_xml(filepath):
|
||||
"""Read a .keylayout file, working around XML 1.1 control character references.
|
||||
|
||||
Apple .keylayout files declare XML 1.1 and use numeric character references
|
||||
for control characters ( through ) that are invalid in XML 1.0.
|
||||
Python's ElementTree only supports XML 1.0, so we convert control character
|
||||
references to placeholder tokens, parse, then restore them.
|
||||
"""
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# downgrade XML declaration from 1.1 to 1.0
|
||||
content = content.replace('version="1.1"', 'version="1.0"')
|
||||
|
||||
# strip the DOCTYPE (references local DTD that may not exist)
|
||||
content = re.sub(r'<!DOCTYPE[^>]*>', '', content)
|
||||
|
||||
# replace control character references with placeholder strings
|
||||
#  through  and  are problematic in XML 1.0
|
||||
def replace_control_ref(m):
|
||||
code_point = int(m.group(1), 16)
|
||||
return f"__CTRL_U{code_point:04X}__"
|
||||
|
||||
content = re.sub(
|
||||
r'&#x(000[0-9A-Fa-f]|001[0-9A-Fa-f]|007[Ff]);',
|
||||
replace_control_ref,
|
||||
content,
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def _restore_control_chars(text):
|
||||
"""Restore placeholder tokens back to actual characters."""
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
def restore(m):
|
||||
code_point = int(m.group(1), 16)
|
||||
return chr(code_point)
|
||||
|
||||
return re.sub(r'__CTRL_U([0-9A-F]{4})__', restore, text)
|
||||
|
||||
|
||||
def parse_keylayout(filepath):
|
||||
"""Parse a .keylayout XML file and return a structured dict."""
|
||||
xml_content = _read_keylayout_xml(filepath)
|
||||
root = ET.fromstring(xml_content)
|
||||
|
||||
result = {
|
||||
"name": root.get("name", ""),
|
||||
"group": root.get("group", ""),
|
||||
"id": root.get("id", ""),
|
||||
}
|
||||
|
||||
# parse modifier map
|
||||
result["modifierMap"] = parse_modifier_map(root)
|
||||
|
||||
# parse all keyMapSets
|
||||
key_map_sets = {}
|
||||
for kms in root.findall(".//keyMapSet"):
|
||||
kms_id = kms.get("id")
|
||||
key_map_sets[kms_id] = parse_key_map_set(kms, key_map_sets)
|
||||
|
||||
# parse actions (dead key compositions)
|
||||
actions = parse_actions(root)
|
||||
result["actions"] = actions
|
||||
|
||||
# parse terminators
|
||||
terminators = {}
|
||||
for term in root.findall(".//terminators/when"):
|
||||
state = term.get("state", "")
|
||||
output = _restore_control_chars(term.get("output", ""))
|
||||
terminators[state] = output
|
||||
result["terminators"] = terminators
|
||||
|
||||
# resolve layouts
|
||||
layouts = root.findall(".//layout")
|
||||
|
||||
# build resolved key maps with all key codes from all layout entries
|
||||
resolved = {}
|
||||
for layout in layouts:
|
||||
map_set_id = layout.get("mapSet")
|
||||
first_code = int(layout.get("first", "0"))
|
||||
last_code = int(layout.get("last", "0"))
|
||||
kms = key_map_sets.get(map_set_id, {})
|
||||
|
||||
for idx_str, keys in kms.items():
|
||||
if idx_str not in resolved:
|
||||
resolved[idx_str] = {}
|
||||
for code_str, entry in keys.items():
|
||||
code = int(code_str)
|
||||
if first_code <= code <= last_code:
|
||||
resolved[idx_str][code_str] = entry
|
||||
|
||||
# build the final keyMaps output
|
||||
key_maps = {}
|
||||
for idx_str in sorted(resolved.keys(), key=int):
|
||||
idx = int(idx_str)
|
||||
label = MODIFIER_LABELS.get(idx, f"Index {idx}")
|
||||
keys = {}
|
||||
for code_str in sorted(resolved[idx_str].keys(), key=int):
|
||||
code = int(code_str)
|
||||
entry = resolved[idx_str][code_str]
|
||||
key_name = KEY_CODE_NAMES.get(code, f"code{code}")
|
||||
key_info = {"code": code, "keyName": key_name}
|
||||
|
||||
if "output" in entry:
|
||||
key_info["output"] = entry["output"]
|
||||
if "action" in entry:
|
||||
action_id = entry["action"]
|
||||
key_info["action"] = action_id
|
||||
# resolve the action's base output
|
||||
if action_id in actions:
|
||||
action_data = actions[action_id]
|
||||
if "none" in action_data:
|
||||
key_info["output"] = action_data["none"]
|
||||
elif "next" in action_data:
|
||||
key_info["deadKey"] = action_data["next"]
|
||||
|
||||
keys[code_str] = key_info
|
||||
key_maps[idx_str] = {"label": label, "keys": keys}
|
||||
|
||||
result["keyMaps"] = key_maps
|
||||
|
||||
# build dead key summary
|
||||
dead_keys = {}
|
||||
for state_name, terminator in terminators.items():
|
||||
compositions = {}
|
||||
for action_id, action_data in actions.items():
|
||||
if state_name in action_data:
|
||||
compositions[action_id] = action_data[state_name]
|
||||
dead_keys[state_name] = {
|
||||
"terminator": terminator,
|
||||
"compositions": compositions,
|
||||
}
|
||||
result["deadKeys"] = dead_keys
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_modifier_map(root):
|
||||
"""Parse the modifierMap element."""
|
||||
mod_map = {}
|
||||
for mm in root.findall(".//modifierMap"):
|
||||
mm_id = mm.get("id")
|
||||
default_index = mm.get("defaultIndex", "")
|
||||
selects = []
|
||||
for kms in mm.findall("keyMapSelect"):
|
||||
map_index = kms.get("mapIndex", "")
|
||||
modifiers = []
|
||||
for mod in kms.findall("modifier"):
|
||||
modifiers.append(mod.get("keys", ""))
|
||||
selects.append({"mapIndex": map_index, "modifiers": modifiers})
|
||||
mod_map[mm_id] = {"defaultIndex": default_index, "selects": selects}
|
||||
return mod_map
|
||||
|
||||
|
||||
def parse_key_map_set(kms_element, all_key_map_sets):
|
||||
"""Parse a keyMapSet element, resolving baseMapSet/baseIndex references."""
|
||||
result = {}
|
||||
for km in kms_element.findall("keyMap"):
|
||||
index = km.get("index")
|
||||
keys = {}
|
||||
|
||||
# resolve base map set if specified
|
||||
base_map_set_id = km.get("baseMapSet")
|
||||
base_index = km.get("baseIndex")
|
||||
if base_map_set_id and base_index:
|
||||
base_kms = all_key_map_sets.get(base_map_set_id, {})
|
||||
base_keys = base_kms.get(base_index, {})
|
||||
keys.update(base_keys)
|
||||
|
||||
# parse keys in this keyMap (override base)
|
||||
for key in km.findall("key"):
|
||||
code = key.get("code")
|
||||
entry = {}
|
||||
if key.get("output") is not None:
|
||||
entry["output"] = _restore_control_chars(key.get("output"))
|
||||
if key.get("action") is not None:
|
||||
entry["action"] = key.get("action")
|
||||
keys[code] = entry
|
||||
|
||||
result[index] = keys
|
||||
return result
|
||||
|
||||
|
||||
def parse_actions(root):
|
||||
"""Parse all action elements into a dict of action_id → {state → output/next}."""
|
||||
actions = {}
|
||||
for action in root.findall(".//actions/action"):
|
||||
action_id = action.get("id")
|
||||
states = {}
|
||||
for when in action.findall("when"):
|
||||
state = when.get("state", "none")
|
||||
if when.get("output") is not None:
|
||||
states[state] = _restore_control_chars(when.get("output"))
|
||||
elif when.get("next") is not None:
|
||||
if state == "none":
|
||||
states["next"] = when.get("next")
|
||||
else:
|
||||
states[state] = f"→{when.get('next')}"
|
||||
actions[action_id] = states
|
||||
return actions
|
||||
|
||||
|
||||
def format_char(c):
|
||||
"""Format a character for display, showing control chars as hex."""
|
||||
if len(c) == 1:
|
||||
cp = ord(c)
|
||||
if cp < 0x20 or cp == 0x7F:
|
||||
return f"U+{cp:04X}"
|
||||
if cp == 0xA0:
|
||||
return "NBSP"
|
||||
return c
|
||||
|
||||
|
||||
def print_summary(data):
|
||||
"""Print a human-readable summary of the parsed layout."""
|
||||
print(f"Layout: {data['name']}")
|
||||
print(f"Dead key states: {', '.join(data['deadKeys'].keys())}")
|
||||
print()
|
||||
|
||||
for idx_str in sorted(data["keyMaps"].keys(), key=int):
|
||||
km = data["keyMaps"][idx_str]
|
||||
print(f"--- {km['label']} (index {idx_str}) ---")
|
||||
for code_str in sorted(km["keys"].keys(), key=int):
|
||||
code = int(code_str)
|
||||
if code not in TYPING_KEY_CODES:
|
||||
continue
|
||||
ki = km["keys"][code_str]
|
||||
key_name = ki["keyName"]
|
||||
output = ki.get("output", "")
|
||||
dead = ki.get("deadKey", "")
|
||||
formatted = format_char(output) if output else ""
|
||||
extra = f" [dead: {dead}]" if dead else ""
|
||||
action = f" (action: {ki['action']})" if "action" in ki else ""
|
||||
print(f" {key_name:>12s} (code {code:>3d}): {formatted}{extra}{action}")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Parse Apple .keylayout XML files")
|
||||
parser.add_argument("keylayout", help="Path to .keylayout file")
|
||||
parser.add_argument("--output", "-o", help="Output JSON file path")
|
||||
parser.add_argument("--summary", "-s", action="store_true",
|
||||
help="Print human-readable summary")
|
||||
args = parser.parse_args()
|
||||
|
||||
data = parse_keylayout(args.keylayout)
|
||||
|
||||
if args.summary:
|
||||
print_summary(data)
|
||||
|
||||
if args.output:
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent="\t")
|
||||
print(f"Written to {output_path}")
|
||||
elif not args.summary:
|
||||
json.dump(data, sys.stdout, ensure_ascii=False, indent="\t")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validate all EurKEY keylayout files against the v1.3 reference spec.
|
||||
# Exit code 0 if all pass, 1 if any unexpected mismatches.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
exec python3 "${SCRIPT_DIR}/validate_layouts.py" "$@"
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate .keylayout files against the EurKEY v1.3 reference spec.
|
||||
|
||||
Compares each layout version's key mappings and dead key compositions against
|
||||
the reference, reporting mismatches. Supports per-version exception lists for
|
||||
intentional differences.
|
||||
|
||||
Usage:
|
||||
python3 scripts/validate_layouts.py [--verbose]
|
||||
|
||||
Exit code 0 if all layouts pass validation, 1 if any unexpected mismatches.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# import the parser
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from parse_keylayout import parse_keylayout, TYPING_KEY_CODES, MODIFIER_LABELS, KEY_CODE_NAMES
|
||||
|
||||
BUNDLE_DIR = Path(__file__).parent.parent / "EurKey-macOS.bundle" / "Contents" / "Resources"
|
||||
|
||||
# modifier indices that contain meaningful typing output
|
||||
# (exclude index 6 = Command+Option and 7 = Control — these are system shortcuts)
|
||||
VALIDATED_MODIFIER_INDICES = {"0", "1", "2", "3", "4", "5"}
|
||||
|
||||
|
||||
def load_layout(version):
|
||||
"""Parse a keylayout file for the given version string."""
|
||||
path = BUNDLE_DIR / f"EurKEY {version}.keylayout"
|
||||
if not path.exists():
|
||||
print(f"ERROR: {path} not found")
|
||||
sys.exit(2)
|
||||
return parse_keylayout(str(path))
|
||||
|
||||
|
||||
def compare_key_maps(reference, target, exceptions):
|
||||
"""Compare key maps between reference and target layouts.
|
||||
|
||||
Returns a list of (modifier_label, key_name, code, ref_output, target_output) tuples
|
||||
for each mismatch that is not in the exceptions list.
|
||||
"""
|
||||
mismatches = []
|
||||
|
||||
# build terminator→state name maps for dead key comparison
|
||||
# build state_name→terminator for both versions
|
||||
ref_state_to_term = {name: dk["terminator"] for name, dk in reference["deadKeys"].items()}
|
||||
tgt_state_to_term = {name: dk["terminator"] for name, dk in target["deadKeys"].items()}
|
||||
|
||||
for idx_str in VALIDATED_MODIFIER_INDICES:
|
||||
ref_km = reference["keyMaps"].get(idx_str, {}).get("keys", {})
|
||||
tgt_km = target["keyMaps"].get(idx_str, {}).get("keys", {})
|
||||
mod_label = MODIFIER_LABELS.get(int(idx_str), f"Index {idx_str}")
|
||||
|
||||
for code_str in ref_km:
|
||||
code = int(code_str)
|
||||
if code not in TYPING_KEY_CODES:
|
||||
continue
|
||||
|
||||
ref_key = ref_km[code_str]
|
||||
tgt_key = tgt_km.get(code_str, {})
|
||||
|
||||
ref_output = ref_key.get("output", "")
|
||||
tgt_output = tgt_key.get("output", "")
|
||||
ref_dead = ref_key.get("deadKey", "")
|
||||
tgt_dead = tgt_key.get("deadKey", "")
|
||||
|
||||
key_name = KEY_CODE_NAMES.get(code, f"code{code}")
|
||||
|
||||
# check for exception
|
||||
exc_key = f"{idx_str}:{code_str}"
|
||||
if exc_key in exceptions:
|
||||
expected = exceptions[exc_key]
|
||||
if expected.get("output") == tgt_output or expected.get("deadKey") == tgt_dead:
|
||||
continue
|
||||
# exception exists but value doesn't match → still a mismatch
|
||||
mismatches.append((
|
||||
mod_label, key_name, code,
|
||||
f"{ref_output or ref_dead} (ref)",
|
||||
f"{tgt_output or tgt_dead} (got, expected exception: {expected})",
|
||||
))
|
||||
continue
|
||||
|
||||
# compare dead keys by terminator (state names may differ)
|
||||
if ref_dead or tgt_dead:
|
||||
ref_term = ref_state_to_term.get(ref_dead, ref_dead)
|
||||
tgt_term = tgt_state_to_term.get(tgt_dead, tgt_dead)
|
||||
if ref_term == tgt_term:
|
||||
continue # same dead key, different name
|
||||
# dead keys differ
|
||||
ref_display = f"[dead: {ref_dead} → {ref_term}]"
|
||||
tgt_display = f"[dead: {tgt_dead} → {tgt_term}]" if tgt_dead else tgt_output or "[missing]"
|
||||
mismatches.append((mod_label, key_name, code, ref_display, tgt_display))
|
||||
continue
|
||||
|
||||
# compare regular outputs
|
||||
if ref_output != tgt_output:
|
||||
if not tgt_output and not tgt_dead:
|
||||
tgt_display = "[missing]"
|
||||
else:
|
||||
tgt_display = tgt_output
|
||||
mismatches.append((mod_label, key_name, code, ref_output, tgt_display))
|
||||
|
||||
return mismatches
|
||||
|
||||
|
||||
def _build_terminator_map(data):
|
||||
"""Build a mapping from terminator character → dead key data.
|
||||
|
||||
Different layout versions may use different state names (e.g., "dead: ^" vs "7")
|
||||
but the same terminator character. This allows matching by terminator.
|
||||
"""
|
||||
return {dk["terminator"]: (name, dk) for name, dk in data["deadKeys"].items()}
|
||||
|
||||
|
||||
def _composition_output_set(compositions):
|
||||
"""Extract the set of output characters from a dead key's compositions.
|
||||
|
||||
Since action IDs differ between versions (e.g., "a" vs "a61"), we compare
|
||||
by the set of output characters produced, not by action ID.
|
||||
"""
|
||||
return set(compositions.values())
|
||||
|
||||
|
||||
def compare_dead_keys(reference, target, exceptions):
|
||||
"""Compare dead key compositions between reference and target.
|
||||
|
||||
Matches dead key states by their terminator character (since state names
|
||||
may differ between versions). Compares composition output sets.
|
||||
|
||||
Returns a list of (dead_key_state, detail, ref_value, target_value) tuples.
|
||||
"""
|
||||
mismatches = []
|
||||
|
||||
ref_by_term = _build_terminator_map(reference)
|
||||
tgt_by_term = _build_terminator_map(target)
|
||||
|
||||
for terminator, (ref_name, ref_dk) in ref_by_term.items():
|
||||
if ref_name in exceptions.get("_dead_key_skip", []):
|
||||
continue
|
||||
|
||||
if terminator not in tgt_by_term:
|
||||
mismatches.append((ref_name, "*", "present", "missing"))
|
||||
continue
|
||||
|
||||
_, tgt_dk = tgt_by_term[terminator]
|
||||
|
||||
# compare composition output sets
|
||||
ref_outputs = _composition_output_set(ref_dk["compositions"])
|
||||
tgt_outputs = _composition_output_set(tgt_dk["compositions"])
|
||||
|
||||
only_ref = ref_outputs - tgt_outputs
|
||||
only_tgt = tgt_outputs - ref_outputs
|
||||
|
||||
for out in sorted(only_ref):
|
||||
exc_key = f"dead:{ref_name}:output:{out}"
|
||||
if exc_key not in exceptions:
|
||||
mismatches.append((ref_name, f"output {out}", "present", "missing"))
|
||||
|
||||
for out in sorted(only_tgt):
|
||||
exc_key = f"dead:{ref_name}:extra:{out}"
|
||||
if exc_key not in exceptions:
|
||||
mismatches.append((ref_name, f"output {out}", "missing", "present"))
|
||||
|
||||
return mismatches
|
||||
|
||||
|
||||
def format_char_display(c):
|
||||
"""Format a character for display."""
|
||||
if not c or c in ("[missing]", "missing", "present"):
|
||||
return c
|
||||
if len(c) == 1:
|
||||
cp = ord(c)
|
||||
if cp < 0x20 or cp == 0x7F:
|
||||
return f"U+{cp:04X}"
|
||||
return c
|
||||
|
||||
|
||||
# --- per-version exception definitions ---
|
||||
# format: {"modifierIndex:keyCode": {"output": "expected_value"}}
|
||||
# or {"_dead_key_skip": ["state_name", ...]} to skip entire dead key states
|
||||
|
||||
# v1.2 predates v1.3 — known differences documented here
|
||||
V1_2_EXCEPTIONS = {
|
||||
# Shift+Option S: v1.2 has § where v1.3 has ẞ (capital sharp s)
|
||||
"4:1": {"output": "§"},
|
||||
# v1.2 does not have the ¬ (negation) dead key — added in v1.3
|
||||
"_dead_key_skip": ["dead: ¬"],
|
||||
}
|
||||
|
||||
# v1.4 differences from v1.3:
|
||||
# - §/` key (code 10) in Caps/Caps+Option outputs ẞ instead of §
|
||||
# - ¬ dead key has an extra ¬ composition (self-referencing)
|
||||
V1_4_EXCEPTIONS = {
|
||||
"2:10": {"output": "ẞ"}, # Caps: §/` → ẞ (capital sharp s)
|
||||
"5:10": {"output": "ẞ"}, # Caps+Option: §/` → ẞ
|
||||
"dead:dead: ¬:extra:¬": True, # extra ¬ composition in negation dead key
|
||||
}
|
||||
|
||||
# v2.0 is a custom edition — skip validation for now, just document diffs
|
||||
V2_0_EXCEPTIONS = {
|
||||
"_skip_validation": True,
|
||||
}
|
||||
|
||||
VERSIONS = {
|
||||
"v1.2": {"file": "v1.2", "exceptions": V1_2_EXCEPTIONS, "label": "EurKEY v1.2"},
|
||||
"v1.3": {"file": "v1.3", "exceptions": {}, "label": "EurKEY v1.3 (reference)"},
|
||||
"v1.4": {"file": "v1.4", "exceptions": V1_4_EXCEPTIONS, "label": "EurKEY v1.4"},
|
||||
"v2.0": {"file": "v2.0", "exceptions": V2_0_EXCEPTIONS, "label": "EurKEY v2.0 (custom)"},
|
||||
}
|
||||
|
||||
|
||||
def validate_version(version_key, reference):
|
||||
"""Validate a single version against the reference. Returns (pass, mismatch_count)."""
|
||||
config = VERSIONS[version_key]
|
||||
exceptions = config["exceptions"]
|
||||
|
||||
if exceptions.get("_skip_validation"):
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {config['label']} — SKIPPED (custom edition)")
|
||||
print(f"{'='*60}")
|
||||
return True, 0
|
||||
|
||||
target = load_layout(config["file"])
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Validating {config['label']} against v1.3 reference")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# compare key maps
|
||||
key_mismatches = compare_key_maps(reference, target, exceptions)
|
||||
dk_mismatches = compare_dead_keys(reference, target, exceptions)
|
||||
|
||||
total = len(key_mismatches) + len(dk_mismatches)
|
||||
|
||||
if key_mismatches:
|
||||
print(f"\n Key mapping mismatches ({len(key_mismatches)}):")
|
||||
for mod_label, key_name, code, ref_out, tgt_out in key_mismatches:
|
||||
print(f" {mod_label:>14s} | {key_name:>12s} (code {code:>3d}): "
|
||||
f"ref={format_char_display(ref_out)} got={format_char_display(tgt_out)}")
|
||||
|
||||
if dk_mismatches:
|
||||
print(f"\n Dead key mismatches ({len(dk_mismatches)}):")
|
||||
for state, action_id, ref_out, tgt_out in dk_mismatches:
|
||||
print(f" {state:>12s} + {action_id}: "
|
||||
f"ref={format_char_display(ref_out)} got={format_char_display(tgt_out)}")
|
||||
|
||||
if total == 0:
|
||||
print(f"\n PASS — no unexpected mismatches")
|
||||
else:
|
||||
print(f"\n FAIL — {total} unexpected mismatch(es)")
|
||||
|
||||
return total == 0, total
|
||||
|
||||
|
||||
def self_validate(reference):
|
||||
"""Validate that v1.3 matches itself (sanity check)."""
|
||||
target = load_layout("v1.3")
|
||||
key_mismatches = compare_key_maps(reference, target, {})
|
||||
dk_mismatches = compare_dead_keys(reference, target, {})
|
||||
total = len(key_mismatches) + len(dk_mismatches)
|
||||
if total > 0:
|
||||
print("INTERNAL ERROR: v1.3 does not match itself!")
|
||||
for m in key_mismatches:
|
||||
print(f" key: {m}")
|
||||
for m in dk_mismatches:
|
||||
print(f" dead: {m}")
|
||||
return False
|
||||
print(" Self-check: v1.3 matches itself ✓")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
print("EurKEY-macOS Layout Validation")
|
||||
print("Reference: EurKEY v1.3")
|
||||
|
||||
# load reference
|
||||
reference = load_layout("v1.3")
|
||||
|
||||
# sanity check
|
||||
if not self_validate(reference):
|
||||
sys.exit(2)
|
||||
|
||||
all_pass = True
|
||||
total_mismatches = 0
|
||||
|
||||
for version_key in VERSIONS:
|
||||
if version_key == "v1.3":
|
||||
continue # skip self-comparison
|
||||
passed, count = validate_version(version_key, reference)
|
||||
if not passed:
|
||||
all_pass = False
|
||||
total_mismatches += count
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
if all_pass:
|
||||
print(" ALL LAYOUTS PASS ✓")
|
||||
else:
|
||||
print(f" VALIDATION FAILED — {total_mismatches} total mismatch(es)")
|
||||
print(f"{'='*60}")
|
||||
|
||||
sys.exit(0 if all_pass else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||