Compare commits
22 Commits
b3cfa280df
...
2026.03.14
| Author | SHA1 | Date | |
|---|---|---|---|
| c5cd3850c3 | |||
| 92422e1bf1 | |||
| 1b9ce5dd86 | |||
| a78c45591f | |||
| f3c793c37f | |||
| fa864b65bc | |||
| 18718e3424 | |||
| a8f5cf4097 | |||
| 57c8d56afa | |||
| f8379103d2 | |||
| a31a682f24 | |||
| 234b29391f | |||
| 3e2fc8f0d9 | |||
| 019075c12c | |||
| 0e1d8c4a85 | |||
| 9592b321b1 | |||
| 859c26f64c | |||
| 5e29ef63d4 | |||
| 079ff0a872 | |||
| 7084817dab | |||
| e16b92051d | |||
| b5e2de535e |
@@ -0,0 +1,44 @@
|
|||||||
|
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: setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: install Python dependencies
|
||||||
|
run: pip install fpdf2
|
||||||
|
|
||||||
|
- name: build bundle
|
||||||
|
run: bash scripts/build-bundle.sh
|
||||||
|
|
||||||
|
- name: validate layouts
|
||||||
|
run: python3 scripts/validate_layouts.py
|
||||||
|
|
||||||
|
- name: create DMG
|
||||||
|
run: bash scripts/build-dmg.sh
|
||||||
|
|
||||||
|
- name: upload DMG
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: EurKEY-macOS-DMG
|
||||||
|
path: build/*.dmg
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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: setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: install Python dependencies
|
||||||
|
run: pip install fpdf2
|
||||||
|
|
||||||
|
- name: extract version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: build bundle
|
||||||
|
run: bash scripts/build-bundle.sh --version "${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
- name: validate layouts
|
||||||
|
run: python3 scripts/validate_layouts.py
|
||||||
|
|
||||||
|
- name: create DMG
|
||||||
|
run: bash scripts/build-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: |
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Fix modifier key order to Apple canonical: Option+Shift (not Shift+Option)
|
||||||
|
- Add EU badge template icons for v1.2, v1.3, v1.4 matching Apple's built-in style
|
||||||
|
- Add icon build pipeline from SVG source
|
||||||
|
- Add interactive keyboard viewer to website
|
||||||
|
- Add layout PDF downloads to website
|
||||||
|
- Replace Hugo with lightweight static website
|
||||||
|
- Fix Greek dead key terminator (Ω → α) to match official spec
|
||||||
|
- Enable CapsLock language switch for all layouts
|
||||||
|
- Rename create-dmg.sh → build-dmg.sh
|
||||||
|
|
||||||
|
## 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,50 @@
|
|||||||
|
name: Deploy website
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'eurkey-macos.eu/**'
|
||||||
|
- 'src/keylayouts/**'
|
||||||
|
- 'fonts/**'
|
||||||
|
- 'scripts/generate_layout_pdf.py'
|
||||||
|
- 'scripts/parse_keylayout.py'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
- name: Generate layout PDFs
|
||||||
|
run: |
|
||||||
|
pip install fpdf2
|
||||||
|
python3 scripts/generate_layout_pdf.py -o eurkey-macos.eu/pdf/
|
||||||
|
- name: Generate layout JSON
|
||||||
|
run: |
|
||||||
|
mkdir -p eurkey-macos.eu/data
|
||||||
|
for ver in v1.2 v1.3 v1.4 v2.0; do
|
||||||
|
python3 scripts/parse_keylayout.py "src/keylayouts/EurKEY ${ver}.keylayout" \
|
||||||
|
-o "eurkey-macos.eu/data/eurkey-${ver}.json"
|
||||||
|
done
|
||||||
|
- uses: actions/configure-pages@v5
|
||||||
|
- uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: eurkey-macos.eu
|
||||||
|
- id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
**/public/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
build/
|
||||||
|
*.dmg
|
||||||
|
|
||||||
|
# Generated site assets
|
||||||
|
eurkey-macos.eu/data/
|
||||||
|
eurkey-macos.eu/pdf/
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- Template badge: rounded square nearly filling the canvas.
|
||||||
|
Matches Apple's built-in keyboard layout icon style (ABC, DE, etc.). -->
|
||||||
|
<defs>
|
||||||
|
<mask id="textmask">
|
||||||
|
<rect width="1024" height="1024" fill="white"/>
|
||||||
|
<text x="512" y="700" text-anchor="middle"
|
||||||
|
font-family="Helvetica, Arial, sans-serif"
|
||||||
|
font-size="640" font-weight="bold"
|
||||||
|
fill="black">EU</text>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="1024" height="1024" rx="220" fill="black" mask="url(#textmask)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 567 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- Draft C: Single key cap with EU star — minimal, reads well at any size -->
|
||||||
|
<rect width="1024" height="1024" rx="180" fill="#003399"/>
|
||||||
|
<!-- Large key cap shape, centered -->
|
||||||
|
<rect x="172" y="172" width="680" height="680" rx="80" fill="rgba(255,255,255,0.92)"
|
||||||
|
stroke="rgba(255,255,255,0.3)" stroke-width="8"/>
|
||||||
|
<!-- Key shadow/depth -->
|
||||||
|
<rect x="172" y="180" width="680" height="680" rx="80" fill="none"
|
||||||
|
stroke="rgba(0,0,0,0.08)" stroke-width="8"/>
|
||||||
|
<!-- Single EU gold star centered on key -->
|
||||||
|
<polygon points="512,220 568,398 756,398 604,506 646,680 512,580 378,680 420,506 268,398 456,398"
|
||||||
|
fill="#FFCC00"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 699 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- Template icon: "EU" — transparent bg, black text
|
||||||
|
macOS TISIconIsTemplate renders this adaptively:
|
||||||
|
light mode: dark text, light pill background
|
||||||
|
dark mode: light text, dark pill background -->
|
||||||
|
<text x="512" y="700" text-anchor="middle" font-family="'SF Pro Text', '.AppleSystemUIFont', 'Helvetica Neue', sans-serif" font-size="620" font-weight="600" fill="#000" letter-spacing="-10">EU</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 490 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- Template icon: "EUR" text — macOS renders black in light mode, white in dark mode -->
|
||||||
|
<text x="512" y="640" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', sans-serif" font-size="480" font-weight="700" fill="#000" letter-spacing="-20">EUR</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 384 B |
|
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.04</string>
|
||||||
|
<key>KLInfo_EurKEY v1.2</key>
|
||||||
|
<dict>
|
||||||
|
<key>TICapsLockLanguageSwitchCapable</key>
|
||||||
|
<true/>
|
||||||
|
<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>
|
||||||
|
<true/>
|
||||||
|
<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>
|
||||||
|
<true/>
|
||||||
|
<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>
|
||||||
|
<true/>
|
||||||
|
<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.04</string>
|
||||||
|
<key>ProjectName</key>
|
||||||
|
<string>EurKEY-macOS</string>
|
||||||
|
<key>SourceVersion</key>
|
||||||
|
<string>2026.03.04</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,23 +1,132 @@
|
|||||||
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 Option+Shift+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).
|
1. Download the latest `EurKEY-macOS-YYYY.MM.DD.dmg` from [Releases](https://github.com/felixfoertsch/EurKEY-macOS/releases).
|
||||||
The EU flag icon is taken from [Iconspedia](http://www.iconspedia.com/pack/european-flags-1631/),
|
2. Open the DMG.
|
||||||
created by [Alpak](http://alpak.deviantart.com/) and
|
3. Drag `EurKey-macOS.bundle` to the `Install Here (Keyboard Layouts)` folder.
|
||||||
licensed under [CC](http://creativecommons.org/licenses/by-nc-nd/3.0)
|
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/build-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 | √ |
|
||||||
|
| ⌥\ | ¬ |
|
||||||
|
|
||||||
|
## Customization with Karabiner-Elements
|
||||||
|
|
||||||
|
macOS `.keylayout` files cannot distinguish between the FN key and other modifiers, and cannot remap FN to act as a custom modifier. To use FN (or any other key) as an additional modifier layer, use [Karabiner-Elements](https://karabiner-elements.pqrs.org/):
|
||||||
|
|
||||||
|
1. Install Karabiner-Elements.
|
||||||
|
2. In **Simple Modifications**, remap `fn` to a modifier key (e.g., `right_option`).
|
||||||
|
3. In **Complex Modifications**, add rules that map your desired key combinations to Unicode character outputs.
|
||||||
|
|
||||||
|
[Hammerspoon](https://www.hammerspoon.org/) is an alternative for Lua-based automation but does not intercept keystrokes at the same level as Karabiner.
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
- **Icon not visible in keyboard switcher badge (macOS Sonoma/Sequoia):** The template icon (which adapts to light/dark mode) disappears in the input source switching badge attached to text fields. This is a macOS bug affecting third-party template icons — Apple's built-in layouts are not affected. Non-template icons work correctly but lose dark mode adaptation.
|
||||||
|
|
||||||
|
## 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 @@
|
|||||||
|
eurkey-macos.eu
|
||||||
|
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,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||||
|
<!-- DMG disk shape (rounded rectangle base) -->
|
||||||
|
<rect x="6" y="26" width="36" height="18" rx="4" stroke="#003399" stroke-width="2.5" fill="none"/>
|
||||||
|
<!-- Slot/opening on the disk -->
|
||||||
|
<line x1="16" y1="38" x2="32" y2="38" stroke="#003399" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<!-- Download arrow -->
|
||||||
|
<line x1="24" y1="4" x2="24" y2="22" stroke="#003399" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<path d="M18 17 L24 23 L30 17" stroke="#003399" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||||
|
<!-- Document with folded corner -->
|
||||||
|
<path d="M10 4 H30 L38 12 V42 Q38 44 36 44 H12 Q10 44 10 42 Z" stroke="#003399" stroke-width="2.5" fill="none"/>
|
||||||
|
<!-- Folded corner -->
|
||||||
|
<path d="M30 4 V12 H38" stroke="#003399" stroke-width="2" fill="none" stroke-linejoin="round"/>
|
||||||
|
<!-- Keyboard diagram lines on the document -->
|
||||||
|
<line x1="16" y1="22" x2="32" y2="22" stroke="#003399" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<line x1="16" y1="27" x2="32" y2="27" stroke="#003399" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<line x1="16" y1="32" x2="28" y2="32" stroke="#003399" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<!-- PDF label -->
|
||||||
|
<text x="24" y="40" text-anchor="middle" font-family="-apple-system, system-ui, sans-serif" font-size="7" font-weight="700" fill="#003399">PDF</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 915 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||||
|
<!-- Stacked key caps representing multiple versions -->
|
||||||
|
<!-- Back card -->
|
||||||
|
<rect x="12" y="6" width="28" height="20" rx="3" stroke="#003399" stroke-width="2" fill="none" opacity="0.3"/>
|
||||||
|
<!-- Middle card -->
|
||||||
|
<rect x="8" y="12" width="28" height="20" rx="3" stroke="#003399" stroke-width="2" fill="none" opacity="0.6"/>
|
||||||
|
<!-- Front card -->
|
||||||
|
<rect x="4" y="18" width="28" height="20" rx="3" stroke="#003399" stroke-width="2.5" fill="none"/>
|
||||||
|
<!-- Version label on front card -->
|
||||||
|
<text x="18" y="32" text-anchor="middle" font-family="-apple-system, system-ui, sans-serif" font-size="10" font-weight="700" fill="#003399">2.0</text>
|
||||||
|
<!-- Checkmark on front card -->
|
||||||
|
<path d="M32 42 L36 46 L44 36" stroke="#003399" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 866 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<!-- EurKEY: EU star on a key cap -->
|
||||||
|
<rect width="128" height="128" rx="28" fill="#003399"/>
|
||||||
|
<rect x="22" y="22" width="84" height="84" rx="12" fill="rgba(255,255,255,0.92)"/>
|
||||||
|
<polygon points="64,28 71,50 94,50 75,62 81,84 64,72 47,84 53,62 34,50 57,50" fill="#FFCC00"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,141 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>EurKEY — The European Keyboard Layout for macOS</title>
|
||||||
|
<meta name="description" content="The keyboard layout for Europeans, coders, and translators. Dead keys for diacritics, ISO international layout, easy DMG install.">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="img/icon.svg">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- NAV -->
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="#" class="nav-logo">
|
||||||
|
<img src="img/icon.svg" alt="" width="32" height="32">
|
||||||
|
<span>EurKEY</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="https://github.com/felixfoertsch/EurKEY-macOS/releases" class="btn btn--nav">Download</a>
|
||||||
|
<a href="https://github.com/felixfoertsch/EurKEY-macOS" class="nav-github" aria-label="GitHub">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- LAYOUT PREVIEW -->
|
||||||
|
<section id="layout" class="layout-preview">
|
||||||
|
<div class="container">
|
||||||
|
<h2>EurKEY — The European Keyboard Layout</h2>
|
||||||
|
<p class="section-sub">The complete key map for each version, showing all modifier layers and dead key compositions.</p>
|
||||||
|
<div class="layout-controls">
|
||||||
|
<div class="version-tabs">
|
||||||
|
<button class="version-tab active" data-version="v2.0">v2.0</button>
|
||||||
|
<button class="version-tab" data-version="v1.4">v1.4</button>
|
||||||
|
<button class="version-tab" data-version="v1.3">v1.3</button>
|
||||||
|
<button class="version-tab" data-version="v1.2">v1.2</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<figure class="keyboard" id="keyboard"></figure>
|
||||||
|
<p class="keyboard-hint">Hold <kbd>Shift</kbd> or <kbd>Option</kbd> to preview modifier layers. Click a yellow dead key to see its compositions. Press <kbd>Esc</kbd> to go back.</p>
|
||||||
|
<div class="layout-pdf-links">
|
||||||
|
<a href="pdf/eurkey-v2.0-layout.pdf" id="pdf-download" class="btn btn--secondary">Download v2.0 PDF</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FEATURES -->
|
||||||
|
<section id="features" class="features">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Features</h2>
|
||||||
|
<div class="feature-grid">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">⌥</div>
|
||||||
|
<h3>Dead Keys for Diacritics</h3>
|
||||||
|
<p>Type accented characters (ä, é, ñ, č, …) using intuitive Option-key combinations. No character palette needed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">⌨</div>
|
||||||
|
<h3>ISO International Layout</h3>
|
||||||
|
<p>Built for the physical English International keyboard found on European MacBooks — with the extra § key and big Enter.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">◇</div>
|
||||||
|
<h3>Multiple Versions</h3>
|
||||||
|
<p>Ships v1.2, v1.3, v1.4, and v2.0 in a single bundle. Pick the one that fits your workflow.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">↓</div>
|
||||||
|
<h3>Easy Install</h3>
|
||||||
|
<p>Download the DMG, drag the bundle to Keyboard Layouts, log out. Done in under a minute.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- INSTALLATION -->
|
||||||
|
<section id="install" class="install">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Installation</h2>
|
||||||
|
<div class="install-steps">
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-number">1</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h3>Download</h3>
|
||||||
|
<p>Get the latest <code>.dmg</code> from <a href="https://github.com/felixfoertsch/EurKEY-macOS/releases">GitHub Releases</a>. Open it and drag <code>EurKey-macOS.bundle</code> to the <em>Keyboard Layouts</em> folder.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-number">2</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h3>Log out</h3>
|
||||||
|
<p>Log out and back in (or restart) so macOS picks up the new layout.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-number">3</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h3>Add the layout</h3>
|
||||||
|
<p>Open <strong>System Settings → Keyboard → Input Sources</strong>. Click <strong>Edit…</strong>, then <strong>+</strong> to add a new input source. Search for "EurKEY" and select the version you want.</p>
|
||||||
|
<div class="install-screenshots">
|
||||||
|
<img src="img/1-input-sources.png" alt="System Settings showing the Edit button for input sources" loading="lazy">
|
||||||
|
<img src="img/2-add-layout.png" alt="Dialog to add a new input source" loading="lazy">
|
||||||
|
<img src="img/3-select-eurkey.png" alt="EurKEY in the input sources list" loading="lazy">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step-number">4</div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h3>Select it</h3>
|
||||||
|
<p>Click the input source icon in the menu bar and switch to EurKEY.</p>
|
||||||
|
<div class="install-screenshots">
|
||||||
|
<img src="img/4-select-input-method.png" alt="Selecting EurKEY from the menu bar dropdown" loading="lazy">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>EurKEY-macOS maintained by <a href="https://github.com/felixfoertsch">Felix Förtsch</a></p>
|
||||||
|
<p>
|
||||||
|
Based on <a href="https://eurkey.steffen.bruentjen.eu/start.html">EurKEY</a> by Steffen Brüntjen
|
||||||
|
· macOS port originally by <a href="https://github.com/lbschenkel/EurKEY-Mac">Leonardo Brondani Schenkel</a>
|
||||||
|
</p>
|
||||||
|
<p>Licensed under <a href="https://www.gnu.org/licenses/gpl-3.0.html">GPLv3</a></p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="keyboard.js"></script>
|
||||||
|
<script data-goatcounter="https://felixfoertsch.goatcounter.com/count"
|
||||||
|
async src="//gc.zgo.at/count.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
/* EurKEY interactive keyboard viewer */
|
||||||
|
|
||||||
|
const KEYBOARD_ROWS = [
|
||||||
|
[
|
||||||
|
[10, 1.0, "§"], [18, 1.0, "1"], [19, 1.0, "2"], [20, 1.0, "3"],
|
||||||
|
[21, 1.0, "4"], [23, 1.0, "5"], [22, 1.0, "6"], [26, 1.0, "7"],
|
||||||
|
[28, 1.0, "8"], [25, 1.0, "9"], [29, 1.0, "0"], [27, 1.0, "-"],
|
||||||
|
[24, 1.0, "="], [null, 1.5, "\u232b"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[null, 1.5, "\u21e5"], [12, 1.0, "Q"], [13, 1.0, "W"], [14, 1.0, "E"],
|
||||||
|
[15, 1.0, "R"], [17, 1.0, "T"], [16, 1.0, "Y"], [32, 1.0, "U"],
|
||||||
|
[34, 1.0, "I"], [31, 1.0, "O"], [35, 1.0, "P"], [33, 1.0, "["],
|
||||||
|
[30, 1.0, "]"], ["spacer", 1.0, ""],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[null, 1.75, "\u21ea"], [0, 1.0, "A"], [1, 1.0, "S"], [2, 1.0, "D"],
|
||||||
|
[3, 1.0, "F"], [5, 1.0, "G"], [4, 1.0, "H"], [38, 1.0, "J"],
|
||||||
|
[40, 1.0, "K"], [37, 1.0, "L"], [41, 1.0, ";"], [39, 1.0, "'"],
|
||||||
|
[42, 1.0, "\\"], ["enter", 0.75, "\u23ce"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[null, 1.25, "\u21e7"], [50, 1.0, "`"], [6, 1.0, "Z"], [7, 1.0, "X"],
|
||||||
|
[8, 1.0, "C"], [9, 1.0, "V"], [11, 1.0, "B"], [45, 1.0, "N"],
|
||||||
|
[46, 1.0, "M"], [43, 1.0, ","], [47, 1.0, "."], [44, 1.0, "/"],
|
||||||
|
[null, 2.25, "\u21e7"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[null, 1.0, "fn"], [null, 1.0, "\u2303"], [null, 1.0, "\u2325"], [null, 1.25, "\u2318"],
|
||||||
|
["spacebar", 5.0, ""], [null, 1.25, "\u2318"], [null, 1.0, "\u2325"],
|
||||||
|
["arrow-cluster", 3.0, ""],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOD_BASE = "0";
|
||||||
|
const MOD_SHIFT = "1";
|
||||||
|
const MOD_OPTION = "3";
|
||||||
|
const MOD_OPTION_SHIFT = "4";
|
||||||
|
|
||||||
|
const LAYERS = [
|
||||||
|
{ mod: MOD_SHIFT, cls: "key-char--shift" },
|
||||||
|
{ mod: MOD_OPTION_SHIFT, cls: "key-char--option-shift" },
|
||||||
|
{ mod: MOD_BASE, cls: "key-char--base" },
|
||||||
|
{ mod: MOD_OPTION, cls: "key-char--option" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOD_LABELS = { "\u21e7": "shift", "\u2325": "option" };
|
||||||
|
|
||||||
|
/* Browser keyCode → macOS key code mapping (US/ISO QWERTY) */
|
||||||
|
const BROWSER_TO_MAC = {
|
||||||
|
65: 0, 83: 1, 68: 2, 70: 3, 72: 4, 71: 5, 90: 6, 88: 7,
|
||||||
|
67: 8, 86: 9, 192: 50, 66: 11, 81: 12, 87: 13, 69: 14, 82: 15,
|
||||||
|
89: 16, 84: 17, 49: 18, 50: 19, 51: 20, 52: 21, 54: 22, 53: 23,
|
||||||
|
187: 24, 57: 25, 55: 26, 189: 27, 56: 28, 48: 29, 221: 30,
|
||||||
|
79: 31, 85: 32, 219: 33, 73: 34, 80: 35, 76: 37, 74: 38,
|
||||||
|
222: 39, 75: 40, 186: 41, 220: 42, 188: 43, 191: 44, 78: 45,
|
||||||
|
77: 46, 190: 47, 191: 44, 171: 24, 173: 27, 61: 24, 59: 41,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEAD_KEY_NAMES = {
|
||||||
|
"\u00b4": "The Acutes", "`": "The Graves", "^": "The Circumflexes",
|
||||||
|
"~": "The Tildes", "\u00a8": "The Umlauts", "\u02c7": "The H\u00e1\u010deks",
|
||||||
|
"\u00af": "The Macrons", "\u02da": "The Rings & Dots",
|
||||||
|
"\u03b1": "The Greeks", "\u221a": "The Mathematicians",
|
||||||
|
"\u00ac": "The Navigators", "\u00a9": "The Navigators",
|
||||||
|
" ": "The Mathematicians",
|
||||||
|
};
|
||||||
|
|
||||||
|
const cache = new Map();
|
||||||
|
let currentVersion = "v2.0";
|
||||||
|
let currentData = null;
|
||||||
|
let currentDeadKey = null;
|
||||||
|
let keyElements = new Map();
|
||||||
|
|
||||||
|
async function loadVersion(version) {
|
||||||
|
if (cache.has(version)) return cache.get(version);
|
||||||
|
const resp = await fetch("data/eurkey-" + version + ".json");
|
||||||
|
if (!resp.ok) throw new Error("Failed to load " + version);
|
||||||
|
const data = await resp.json();
|
||||||
|
cache.set(version, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function charForKey(data, modIndex, keyCode) {
|
||||||
|
const keyMap = data.keyMaps[modIndex];
|
||||||
|
if (!keyMap) return null;
|
||||||
|
const key = keyMap.keys[String(keyCode)];
|
||||||
|
if (!key) return null;
|
||||||
|
if (key.deadKey) {
|
||||||
|
const terminator = data.terminators[key.deadKey] || data.deadKeys[key.deadKey]?.terminator;
|
||||||
|
return { char: terminator || "\u25c6", deadKey: key.deadKey };
|
||||||
|
}
|
||||||
|
return { char: key.output || "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayChar(ch) {
|
||||||
|
if (!ch || ch === " ") return "\u00a0";
|
||||||
|
if (ch === "\u00a0") return "\u237d";
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearElement(el) {
|
||||||
|
while (el.firstChild) el.removeChild(el.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Build composition lookup for a dead key --- */
|
||||||
|
|
||||||
|
function buildCompositionMap(data, deadState) {
|
||||||
|
const dk = data.deadKeys[deadState];
|
||||||
|
if (!dk || !dk.compositions) return null;
|
||||||
|
|
||||||
|
const charMap = {};
|
||||||
|
for (const [actionId, composed] of Object.entries(dk.compositions)) {
|
||||||
|
const action = data.actions[actionId];
|
||||||
|
const base = action?.none || actionId;
|
||||||
|
if (base) charMap[base] = composed;
|
||||||
|
}
|
||||||
|
return charMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Render keyboard --- */
|
||||||
|
|
||||||
|
function renderKeyboard(data) {
|
||||||
|
const kb = document.getElementById("keyboard");
|
||||||
|
clearElement(kb);
|
||||||
|
keyElements.clear();
|
||||||
|
|
||||||
|
for (const row of KEYBOARD_ROWS) {
|
||||||
|
const rowEl = document.createElement("div");
|
||||||
|
rowEl.className = "keyboard-row";
|
||||||
|
|
||||||
|
for (const [keyCode, width, label] of row) {
|
||||||
|
const keyEl = document.createElement("div");
|
||||||
|
keyEl.className = "key";
|
||||||
|
keyEl.style.setProperty("--w", width);
|
||||||
|
|
||||||
|
if (keyCode === "arrow-cluster") {
|
||||||
|
keyEl.classList.add("key--arrow-cluster");
|
||||||
|
|
||||||
|
function makeArrowKey(sym) {
|
||||||
|
const ak = document.createElement("div");
|
||||||
|
ak.className = "arrow-key key--mod";
|
||||||
|
const lbl = document.createElement("span");
|
||||||
|
lbl.className = "key-mod-label";
|
||||||
|
lbl.textContent = sym;
|
||||||
|
ak.appendChild(lbl);
|
||||||
|
return ak;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topRow = document.createElement("div");
|
||||||
|
topRow.className = "arrow-row";
|
||||||
|
const spacerL = document.createElement("div");
|
||||||
|
spacerL.className = "arrow-key arrow-key--spacer";
|
||||||
|
const spacerR = document.createElement("div");
|
||||||
|
spacerR.className = "arrow-key arrow-key--spacer";
|
||||||
|
topRow.append(spacerL, makeArrowKey("\u25b2"), spacerR);
|
||||||
|
|
||||||
|
const bottomRow = document.createElement("div");
|
||||||
|
bottomRow.className = "arrow-row";
|
||||||
|
for (const sym of ["\u25c0", "\u25bc", "\u25b6"]) {
|
||||||
|
bottomRow.appendChild(makeArrowKey(sym));
|
||||||
|
}
|
||||||
|
|
||||||
|
keyEl.append(topRow, bottomRow);
|
||||||
|
} else if (keyCode === "spacebar") {
|
||||||
|
keyEl.classList.add("key--mod", "key--spacebar");
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "key-mod-label";
|
||||||
|
span.textContent = label;
|
||||||
|
keyEl.appendChild(span);
|
||||||
|
} else if (keyCode === "spacer") {
|
||||||
|
keyEl.classList.add("key--spacer");
|
||||||
|
} else if (keyCode === "enter") {
|
||||||
|
keyEl.classList.add("key--mod", "key--enter");
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "key-mod-label";
|
||||||
|
span.textContent = label;
|
||||||
|
keyEl.appendChild(span);
|
||||||
|
} else if (keyCode === null) {
|
||||||
|
keyEl.classList.add("key--mod");
|
||||||
|
if (MOD_LABELS[label]) keyEl.dataset.mod = MOD_LABELS[label];
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "key-mod-label";
|
||||||
|
span.textContent = label;
|
||||||
|
keyEl.appendChild(span);
|
||||||
|
} else {
|
||||||
|
keyElements.set(keyCode, keyEl);
|
||||||
|
|
||||||
|
let hasDead = false;
|
||||||
|
let deadState = null;
|
||||||
|
|
||||||
|
for (const layer of LAYERS) {
|
||||||
|
const info = charForKey(data, layer.mod, keyCode);
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "key-char " + layer.cls;
|
||||||
|
if (info) {
|
||||||
|
span.textContent = displayChar(info.char);
|
||||||
|
if (info.deadKey) {
|
||||||
|
hasDead = true;
|
||||||
|
deadState = info.deadKey;
|
||||||
|
span.classList.add("key-char--is-dead");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyEl.appendChild(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDead) {
|
||||||
|
keyEl.classList.add("key--dead");
|
||||||
|
keyEl.dataset.deadKey = deadState;
|
||||||
|
keyEl.addEventListener("click", () => toggleDeadKeyMode(deadState));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rowEl.appendChild(keyEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
kb.appendChild(rowEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Dead key mode --- */
|
||||||
|
|
||||||
|
function toggleDeadKeyMode(deadState) {
|
||||||
|
if (currentDeadKey === deadState) {
|
||||||
|
exitDeadKeyMode();
|
||||||
|
} else {
|
||||||
|
enterDeadKeyMode(deadState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterDeadKeyMode(deadState) {
|
||||||
|
if (!currentData) return;
|
||||||
|
const charMap = buildCompositionMap(currentData, deadState);
|
||||||
|
if (!charMap) return;
|
||||||
|
|
||||||
|
// clean up previous dead key mode if active
|
||||||
|
if (currentDeadKey) exitDeadKeyMode();
|
||||||
|
|
||||||
|
currentDeadKey = deadState;
|
||||||
|
const kb = document.getElementById("keyboard");
|
||||||
|
kb.classList.add("keyboard--dead-mode");
|
||||||
|
|
||||||
|
// show catchy name on spacebar
|
||||||
|
const dk = currentData.deadKeys[deadState];
|
||||||
|
const terminator = dk?.terminator || "";
|
||||||
|
const catchy = DEAD_KEY_NAMES[terminator] || "";
|
||||||
|
const spaceBar = kb.querySelector(".key--spacebar");
|
||||||
|
if (spaceBar) {
|
||||||
|
spaceBar.querySelector(".key-mod-label").textContent = catchy;
|
||||||
|
spaceBar.classList.add("key--spacebar-label");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [keyCode, keyEl] of keyElements) {
|
||||||
|
const codeStr = String(keyCode);
|
||||||
|
const baseKey = currentData.keyMaps[MOD_BASE]?.keys[codeStr];
|
||||||
|
const shiftKey = currentData.keyMaps[MOD_SHIFT]?.keys[codeStr];
|
||||||
|
const baseChar = baseKey?.output || "";
|
||||||
|
const shiftChar = shiftKey?.output || "";
|
||||||
|
|
||||||
|
const baseComposed = charMap[baseChar] || "";
|
||||||
|
const shiftComposed = charMap[shiftChar] || "";
|
||||||
|
|
||||||
|
const spans = keyEl.querySelectorAll(".key-char");
|
||||||
|
// order: shift, option-shift, base, option
|
||||||
|
if (spans[0]) spans[0].textContent = displayChar(shiftComposed);
|
||||||
|
if (spans[1]) spans[1].textContent = "";
|
||||||
|
if (spans[2]) spans[2].textContent = displayChar(baseComposed);
|
||||||
|
if (spans[3]) spans[3].textContent = "";
|
||||||
|
|
||||||
|
if (keyEl.dataset.deadKey === deadState) {
|
||||||
|
keyEl.classList.add("key--dead-active");
|
||||||
|
} else if (baseComposed || shiftComposed) {
|
||||||
|
keyEl.classList.add("key--has-composition");
|
||||||
|
keyEl.classList.remove("key--no-composition");
|
||||||
|
} else {
|
||||||
|
keyEl.classList.add("key--no-composition");
|
||||||
|
keyEl.classList.remove("key--has-composition");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitDeadKeyMode() {
|
||||||
|
if (!currentDeadKey || !currentData) return;
|
||||||
|
currentDeadKey = null;
|
||||||
|
|
||||||
|
const kb = document.getElementById("keyboard");
|
||||||
|
kb.classList.remove("keyboard--dead-mode");
|
||||||
|
|
||||||
|
// restore spacebar
|
||||||
|
const spaceBar = kb.querySelector(".key--spacebar");
|
||||||
|
if (spaceBar) {
|
||||||
|
spaceBar.querySelector(".key-mod-label").textContent = "";
|
||||||
|
spaceBar.classList.remove("key--spacebar-label");
|
||||||
|
}
|
||||||
|
|
||||||
|
// restore original characters
|
||||||
|
for (const [keyCode, keyEl] of keyElements) {
|
||||||
|
keyEl.classList.remove("key--has-composition", "key--no-composition", "key--dead-active");
|
||||||
|
|
||||||
|
const spans = keyEl.querySelectorAll(".key-char");
|
||||||
|
const layerOrder = [MOD_SHIFT, MOD_OPTION_SHIFT, MOD_BASE, MOD_OPTION];
|
||||||
|
for (let i = 0; i < spans.length; i++) {
|
||||||
|
const info = charForKey(currentData, layerOrder[i], keyCode);
|
||||||
|
spans[i].textContent = info ? displayChar(info.char) : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const kb = document.getElementById("keyboard");
|
||||||
|
clearElement(kb);
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.className = "keyboard-error";
|
||||||
|
p.textContent = msg;
|
||||||
|
kb.appendChild(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Modifier key detection --- */
|
||||||
|
|
||||||
|
const activeModifiers = new Set();
|
||||||
|
|
||||||
|
function updateActiveLayer() {
|
||||||
|
const kb = document.getElementById("keyboard");
|
||||||
|
const shift = activeModifiers.has("shift");
|
||||||
|
const option = activeModifiers.has("option");
|
||||||
|
let layer = null;
|
||||||
|
if (shift && option) layer = "option-shift";
|
||||||
|
else if (shift) layer = "shift";
|
||||||
|
else if (option) layer = "option";
|
||||||
|
|
||||||
|
if (layer) {
|
||||||
|
kb.dataset.activeLayer = layer;
|
||||||
|
} else {
|
||||||
|
delete kb.dataset.activeLayer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveModIndex() {
|
||||||
|
const shift = activeModifiers.has("shift");
|
||||||
|
const option = activeModifiers.has("option");
|
||||||
|
if (shift && option) return MOD_OPTION_SHIFT;
|
||||||
|
if (shift) return MOD_SHIFT;
|
||||||
|
if (option) return MOD_OPTION;
|
||||||
|
return MOD_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Shift") activeModifiers.add("shift");
|
||||||
|
if (e.key === "Alt") activeModifiers.add("option");
|
||||||
|
updateActiveLayer();
|
||||||
|
|
||||||
|
// detect dead key trigger from physical keyboard
|
||||||
|
if (currentData && !e.metaKey && !e.ctrlKey) {
|
||||||
|
const macCode = BROWSER_TO_MAC[e.keyCode];
|
||||||
|
if (macCode !== undefined) {
|
||||||
|
const modIdx = getActiveModIndex();
|
||||||
|
const keyMap = currentData.keyMaps[modIdx];
|
||||||
|
const keyData = keyMap?.keys[String(macCode)];
|
||||||
|
if (keyData?.deadKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleDeadKeyMode(keyData.deadKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape exits dead key mode
|
||||||
|
if (e.key === "Escape" && currentDeadKey) {
|
||||||
|
exitDeadKeyMode();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keyup", (e) => {
|
||||||
|
if (e.key === "Shift") activeModifiers.delete("shift");
|
||||||
|
if (e.key === "Alt") activeModifiers.delete("option");
|
||||||
|
updateActiveLayer();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("blur", () => {
|
||||||
|
activeModifiers.clear();
|
||||||
|
updateActiveLayer();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- Version tabs --- */
|
||||||
|
|
||||||
|
function updatePdfLink() {
|
||||||
|
const link = document.getElementById("pdf-download");
|
||||||
|
if (!link) return;
|
||||||
|
link.href = "pdf/eurkey-" + currentVersion + "-layout.pdf";
|
||||||
|
link.textContent = "Download " + currentVersion + " PDF";
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTabs() {
|
||||||
|
const tabs = document.querySelectorAll(".version-tab");
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener("click", async () => {
|
||||||
|
const version = tab.dataset.version;
|
||||||
|
if (version === currentVersion) return;
|
||||||
|
|
||||||
|
tabs.forEach(t => t.classList.remove("active"));
|
||||||
|
tab.classList.add("active");
|
||||||
|
currentVersion = version;
|
||||||
|
currentDeadKey = null;
|
||||||
|
|
||||||
|
updatePdfLink();
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentData = await loadVersion(version);
|
||||||
|
renderKeyboard(currentData);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load layout:", e);
|
||||||
|
showError("Failed to load layout data.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Init --- */
|
||||||
|
|
||||||
|
initTabs();
|
||||||
|
loadVersion(currentVersion).then(data => {
|
||||||
|
currentData = data;
|
||||||
|
renderKeyboard(data);
|
||||||
|
}).catch(e => {
|
||||||
|
console.error("Failed to load initial layout:", e);
|
||||||
|
showError("Failed to load layout data.");
|
||||||
|
});
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
/* Reset & base */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-padding-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: #1a1a2e;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #003399;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav */
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-inner {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #1a1a2e;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo img {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: #5a6178;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: #003399;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links .btn--nav {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links .btn--nav:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-github {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: #003399;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #002266;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--nav {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: #003399;
|
||||||
|
border: 1px solid #003399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary:hover {
|
||||||
|
background: #f0f4ff;
|
||||||
|
color: #002266;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features */
|
||||||
|
.features {
|
||||||
|
padding: 4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background: #f5f7fa;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #5a6178;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout preview */
|
||||||
|
.layout-preview {
|
||||||
|
padding: 4rem 0;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-preview h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-sub {
|
||||||
|
text-align: center;
|
||||||
|
color: #5a6178;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version tabs */
|
||||||
|
.layout-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: #e0e4ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-tab {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5a6178;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-tab:hover {
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-tab.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #1a1a2e;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard */
|
||||||
|
.keyboard {
|
||||||
|
--key-unit: 44px;
|
||||||
|
--key-gap: 6px;
|
||||||
|
--row-gap: 6px;
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(to bottom, #dcdcdc, #d8d8d8 95%, #e2e2e2);
|
||||||
|
border: 1px solid #a0a0a0;
|
||||||
|
border-radius: 7px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 #8c8c8c,
|
||||||
|
0 2px 0 #969696,
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--row-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--key-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key {
|
||||||
|
width: calc(var(--w) * var(--key-unit) + (var(--w) - 1) * var(--key-gap));
|
||||||
|
height: var(--key-unit);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(to bottom, #f9f9f9, #efefef 95%, #e2e2e2);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 #fafafa,
|
||||||
|
0 2px 0 1px #828282,
|
||||||
|
0 2px 0 2px #464646;
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
padding: 3px 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: "SF Mono", "Menlo", "Consolas", monospace;
|
||||||
|
cursor: default;
|
||||||
|
transition: transform 0.08s, box-shadow 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow:
|
||||||
|
0 0.5px 0 1px #828282,
|
||||||
|
0 0.5px 0 2px #464646;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Character positions */
|
||||||
|
.key-char {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-char--shift {
|
||||||
|
justify-content: flex-start;
|
||||||
|
color: #0028aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-char--option-shift {
|
||||||
|
justify-content: flex-end;
|
||||||
|
color: #780078;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-char--base {
|
||||||
|
justify-content: flex-start;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-char--option {
|
||||||
|
justify-content: flex-end;
|
||||||
|
color: #aa0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-char--is-dead {
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-style: dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dead key highlight */
|
||||||
|
.key--dead {
|
||||||
|
background: linear-gradient(to bottom, #fef9e7, #fef3cd 95%, #f5e6a8);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key--dead:hover {
|
||||||
|
background: linear-gradient(to bottom, #fef3cd, #fce9a0 95%, #f0da88);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modifier keys */
|
||||||
|
.key--mod {
|
||||||
|
background: linear-gradient(to bottom, #eeeeee, #e4e4e4 95%, #d8d8d8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-mod-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: #555;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invisible spacer (reserves space next to spanning enter key) */
|
||||||
|
.key--spacer {
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ISO Enter — single tall block in row 3, extending upward into row 2 */
|
||||||
|
.key--enter {
|
||||||
|
height: calc(2 * var(--key-unit) + var(--row-gap));
|
||||||
|
margin-top: calc(-1 * var(--key-unit) - var(--row-gap));
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arrow cluster — inverted T, all half-height */
|
||||||
|
.key--arrow-cluster {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key--arrow-cluster:active {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--key-gap);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-key {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(to bottom, #eeeeee, #e4e4e4 95%, #d8d8d8);
|
||||||
|
box-shadow:
|
||||||
|
0 0.5px 0 #fafafa,
|
||||||
|
0 1px 0 1px #828282,
|
||||||
|
0 1px 0 2px #464646;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-key:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow:
|
||||||
|
0 0.5px 0 1px #828282,
|
||||||
|
0 0.5px 0 2px #464646;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-key .key-mod-label {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-key--spacer {
|
||||||
|
visibility: hidden;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active modifier layer — dim inactive characters, emphasize active */
|
||||||
|
.keyboard[data-active-layer] .key-char {
|
||||||
|
opacity: 0.15;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard[data-active-layer="shift"] .key-char--shift,
|
||||||
|
.keyboard[data-active-layer="option"] .key-char--option,
|
||||||
|
.keyboard[data-active-layer="option-shift"] .key-char--option-shift {
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pressed modifier key visual */
|
||||||
|
.keyboard[data-active-layer="shift"] .key--mod[data-mod="shift"],
|
||||||
|
.keyboard[data-active-layer="option"] .key--mod[data-mod="option"],
|
||||||
|
.keyboard[data-active-layer="option-shift"] .key--mod[data-mod="shift"],
|
||||||
|
.keyboard[data-active-layer="option-shift"] .key--mod[data-mod="option"] {
|
||||||
|
transform: translateY(1px);
|
||||||
|
background: linear-gradient(to bottom, #d4d4d4, #ccc);
|
||||||
|
box-shadow:
|
||||||
|
0 0.5px 0 1px #828282,
|
||||||
|
0 0.5px 0 2px #464646;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dead key mode — compositions shown on keyboard */
|
||||||
|
.keyboard--dead-mode .key--no-composition {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard--dead-mode .key--has-composition {
|
||||||
|
background: linear-gradient(to bottom, #fef9e7, #fef3cd 95%, #f5e6a8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard--dead-mode .key--has-composition .key-char--option-shift,
|
||||||
|
.keyboard--dead-mode .key--has-composition .key-char--option {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard--dead-mode .key--has-composition .key-char--base {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard--dead-mode .key--has-composition .key-char--shift {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard--dead-mode .key--spacebar-label .key-mod-label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard--dead-mode .key--dead-active {
|
||||||
|
background: linear-gradient(to bottom, #d4e8ff, #b8d4f0 95%, #a0c0e0);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Keyboard hint */
|
||||||
|
.keyboard-hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #5a6178;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-hint kbd {
|
||||||
|
background: #e0e4ed;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
font-family: "SF Mono", "Menlo", "Consolas", monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PDF links */
|
||||||
|
.layout-pdf-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-pdf-links a {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #5a6178;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-pdf-links a:hover {
|
||||||
|
color: #003399;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.keyboard-error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #aa0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Installation */
|
||||||
|
.install {
|
||||||
|
padding: 4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-steps {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:not(:last-child)::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 1.25rem;
|
||||||
|
top: 3rem;
|
||||||
|
bottom: -1.25rem;
|
||||||
|
width: 2px;
|
||||||
|
background: #e0e4ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
background: #003399;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content p {
|
||||||
|
color: #5a6178;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content code {
|
||||||
|
background: #f0f4ff;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-screenshots {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-screenshots img {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
padding: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #5a6178;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #5a6178;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover {
|
||||||
|
color: #003399;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 830px) {
|
||||||
|
.keyboard {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.feature-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.nav-links a:not(.nav-github) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-screenshots {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Fonts
|
||||||
|
|
||||||
|
Bundled fonts used for PDF generation and the project website.
|
||||||
|
|
||||||
|
## Iosevka Fixed
|
||||||
|
|
||||||
|
- **Version:** 34.2.1
|
||||||
|
- **Variant:** Iosevka Fixed (monospace, no ligatures)
|
||||||
|
- **Weights:** Regular, Bold
|
||||||
|
- **License:** SIL Open Font License 1.1 (see `iosevka/LICENSE.md`)
|
||||||
|
- **Source:** <https://github.com/be5invis/Iosevka/releases/tag/v34.2.1>
|
||||||
|
- **Glyphs:** 7500+ — covers all EurKEY characters including Greek, Latin Extended, math symbols, and combining diacriticals
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# SIL Open Font License 1.1
|
||||||
|
|
||||||
|
Copyright 2015-2024 Renzhi Li (aka. Belleve Invis, belleve@typeof.net)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
<https://openfontlicense.org>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SIL OPEN FONT LICENSE Version 1.1 — 26 February 2007
|
||||||
|
|
||||||
|
### PREAMBLE
|
||||||
|
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
### DEFINITIONS
|
||||||
|
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting — in part or in whole — any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
### PERMISSION & CONDITIONS
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1. Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2. Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3. No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5. The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
### TERMINATION
|
||||||
|
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
### DISCLAIMER
|
||||||
|
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/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)"
|
||||||
|
BUILD_DIR="${PROJECT_DIR}/build"
|
||||||
|
BUNDLE_DIR="${BUILD_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")
|
||||||
|
|
||||||
|
SRC_DIR="${PROJECT_DIR}/src"
|
||||||
|
|
||||||
|
echo "Building ${BUNDLE_NAME} ${VERSION}"
|
||||||
|
echo "Bundle: ${BUNDLE_DIR}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# --- generate icons from SVG sources ---
|
||||||
|
bash "${SCRIPT_DIR}/build-icons.sh"
|
||||||
|
|
||||||
|
# --- assemble bundle from src/ ---
|
||||||
|
rm -rf "${BUNDLE_DIR}"
|
||||||
|
mkdir -p "${RESOURCES_DIR}"
|
||||||
|
|
||||||
|
for ver in "${VERSIONS[@]}"; do
|
||||||
|
cp "${SRC_DIR}/keylayouts/EurKEY ${ver}.keylayout" "${RESOURCES_DIR}/"
|
||||||
|
cp "${SRC_DIR}/icons/EurKEY ${ver}.icns" "${RESOURCES_DIR}/"
|
||||||
|
done
|
||||||
|
|
||||||
|
for lang in en de es; do
|
||||||
|
mkdir -p "${RESOURCES_DIR}/${lang}.lproj"
|
||||||
|
cp "${SRC_DIR}/lproj/${lang}.lproj/InfoPlist.strings" "${RESOURCES_DIR}/${lang}.lproj/"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Assembled bundle from src/"
|
||||||
|
|
||||||
|
# --- 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>
|
||||||
|
<true/>
|
||||||
|
<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,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create a .dmg installer for EurKEY-macOS keyboard layouts.
|
||||||
|
#
|
||||||
|
# The DMG contains the keyboard layout bundle, layout PDFs, and a symlink
|
||||||
|
# to /Library/Keyboard Layouts/ for drag-and-drop installation.
|
||||||
|
#
|
||||||
|
# Auto-builds the bundle and PDFs if missing.
|
||||||
|
#
|
||||||
|
# Usage: bash scripts/build-dmg.sh [--version YYYY.MM.DD]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
BUILD_DIR="${PROJECT_DIR}/build"
|
||||||
|
BUNDLE_DIR="${BUILD_DIR}/EurKey-macOS.bundle"
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
|
||||||
|
# --- build bundle (includes validation) ---
|
||||||
|
bash "${SCRIPT_DIR}/build-bundle.sh" --version "${VERSION}"
|
||||||
|
|
||||||
|
# --- auto-build PDFs ---
|
||||||
|
echo "Building PDFs..."
|
||||||
|
bash "${SCRIPT_DIR}/build-pdf.sh" --output "${BUILD_DIR}"
|
||||||
|
|
||||||
|
# --- prepare staging directory ---
|
||||||
|
rm -rf "${STAGING_DIR}"
|
||||||
|
mkdir -p "${STAGING_DIR}"
|
||||||
|
|
||||||
|
# copy the bundle
|
||||||
|
cp -R "${BUNDLE_DIR}" "${STAGING_DIR}/"
|
||||||
|
|
||||||
|
# copy layout PDFs
|
||||||
|
cp "${BUILD_DIR}"/eurkey-*-layout.pdf "${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,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generate .icns icon files from SVG sources.
|
||||||
|
#
|
||||||
|
# Requires: rsvg-convert (librsvg), iconutil (macOS built-in)
|
||||||
|
#
|
||||||
|
# The v1.2/v1.3/v1.4 icon is a template badge with "EU" text.
|
||||||
|
# The v2.0 icon is a monochrome star ring (managed separately).
|
||||||
|
#
|
||||||
|
# Usage: bash scripts/build-icons.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
ICON_DIR="${PROJECT_DIR}/src/icons"
|
||||||
|
SVG_DIR="${PROJECT_DIR}/EurKEY-macOS-icon/drafts"
|
||||||
|
|
||||||
|
BADGE_SVG="${SVG_DIR}/badge-eu-template.svg"
|
||||||
|
|
||||||
|
if ! command -v rsvg-convert &> /dev/null; then
|
||||||
|
echo "SKIP: rsvg-convert not found (install librsvg for icon generation)"
|
||||||
|
echo "Using existing .icns files from src/icons/"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${BADGE_SVG}" ]]; then
|
||||||
|
echo "ERROR: ${BADGE_SVG} not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ICONSET="$(mktemp -d)/badge-eu.iconset"
|
||||||
|
mkdir -p "${ICONSET}"
|
||||||
|
|
||||||
|
echo "Generating EU badge icon..."
|
||||||
|
|
||||||
|
# render at all required sizes
|
||||||
|
for size in 16 32 64 128 256 512 1024; do
|
||||||
|
rsvg-convert -w "${size}" -h "${size}" "${BADGE_SVG}" -o "${ICONSET}/tmp_${size}.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
# map to iconset naming convention
|
||||||
|
cp "${ICONSET}/tmp_16.png" "${ICONSET}/icon_16x16.png"
|
||||||
|
cp "${ICONSET}/tmp_32.png" "${ICONSET}/icon_16x16@2x.png"
|
||||||
|
cp "${ICONSET}/tmp_32.png" "${ICONSET}/icon_32x32.png"
|
||||||
|
cp "${ICONSET}/tmp_64.png" "${ICONSET}/icon_32x32@2x.png"
|
||||||
|
cp "${ICONSET}/tmp_128.png" "${ICONSET}/icon_128x128.png"
|
||||||
|
cp "${ICONSET}/tmp_256.png" "${ICONSET}/icon_128x128@2x.png"
|
||||||
|
cp "${ICONSET}/tmp_256.png" "${ICONSET}/icon_256x256.png"
|
||||||
|
cp "${ICONSET}/tmp_512.png" "${ICONSET}/icon_256x256@2x.png"
|
||||||
|
cp "${ICONSET}/tmp_512.png" "${ICONSET}/icon_512x512.png"
|
||||||
|
cp "${ICONSET}/tmp_1024.png" "${ICONSET}/icon_512x512@2x.png"
|
||||||
|
rm "${ICONSET}"/tmp_*.png
|
||||||
|
|
||||||
|
# convert to .icns
|
||||||
|
ICNS_PATH="${ICON_DIR}/badge-eu.icns"
|
||||||
|
iconutil --convert icns --output "${ICNS_PATH}" "${ICONSET}"
|
||||||
|
|
||||||
|
# install for v1.2, v1.3, v1.4 (v2.0 keeps its own icon)
|
||||||
|
cp "${ICNS_PATH}" "${ICON_DIR}/EurKEY v1.2.icns"
|
||||||
|
cp "${ICNS_PATH}" "${ICON_DIR}/EurKEY v1.3.icns"
|
||||||
|
cp "${ICNS_PATH}" "${ICON_DIR}/EurKEY v1.4.icns"
|
||||||
|
rm "${ICNS_PATH}"
|
||||||
|
|
||||||
|
# clean up
|
||||||
|
rm -rf "$(dirname "${ICONSET}")"
|
||||||
|
|
||||||
|
echo "Icons generated for v1.2, v1.3, v1.4"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generate keyboard layout PDFs from .keylayout files.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# auto-install Python dependencies
|
||||||
|
python3 -c "import fpdf" 2>/dev/null || pip3 install --quiet fpdf2
|
||||||
|
|
||||||
|
exec python3 "${SCRIPT_DIR}/generate_layout_pdf.py" "$@"
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate keyboard layout PDFs from .keylayout files.
|
||||||
|
|
||||||
|
Renders an ISO keyboard diagram showing Base, Shift, Option, and
|
||||||
|
Option+Shift layers on each key, plus dead key composition tables.
|
||||||
|
|
||||||
|
Requires: fpdf2 (pip install fpdf2)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/generate_layout_pdf.py # all versions
|
||||||
|
python3 scripts/generate_layout_pdf.py -v v1.3 # specific version
|
||||||
|
python3 scripts/generate_layout_pdf.py -o docs/ # custom output dir
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from parse_keylayout import parse_keylayout, TYPING_KEY_CODES
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fpdf import FPDF
|
||||||
|
except ImportError:
|
||||||
|
print("ERROR: fpdf2 required for PDF generation")
|
||||||
|
print("Install with: pip install fpdf2")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Physical ISO keyboard layout ---
|
||||||
|
# Each entry: (key_code, width_in_units, physical_label)
|
||||||
|
# key_code = None for non-typing keys (modifiers, spacers)
|
||||||
|
KEYBOARD_ROWS = [
|
||||||
|
# Row 0: Number row
|
||||||
|
[
|
||||||
|
(10, 1.0, "§"), (18, 1.0, "1"), (19, 1.0, "2"), (20, 1.0, "3"),
|
||||||
|
(21, 1.0, "4"), (23, 1.0, "5"), (22, 1.0, "6"), (26, 1.0, "7"),
|
||||||
|
(28, 1.0, "8"), (25, 1.0, "9"), (29, 1.0, "0"), (27, 1.0, "-"),
|
||||||
|
(24, 1.0, "="), (None, 1.5, "⌫"),
|
||||||
|
],
|
||||||
|
# Row 1: QWERTY row (1.0u spacer at end for ISO enter)
|
||||||
|
[
|
||||||
|
(None, 1.5, "⇥"), (12, 1.0, "Q"), (13, 1.0, "W"), (14, 1.0, "E"),
|
||||||
|
(15, 1.0, "R"), (17, 1.0, "T"), (16, 1.0, "Y"), (32, 1.0, "U"),
|
||||||
|
(34, 1.0, "I"), (31, 1.0, "O"), (35, 1.0, "P"), (33, 1.0, "["),
|
||||||
|
(30, 1.0, "]"), ("spacer", 1.0, ""),
|
||||||
|
],
|
||||||
|
# Row 2: Home row (0.75u enter spanning into row 1)
|
||||||
|
[
|
||||||
|
(None, 1.75, "⇪"), (0, 1.0, "A"), (1, 1.0, "S"), (2, 1.0, "D"),
|
||||||
|
(3, 1.0, "F"), (5, 1.0, "G"), (4, 1.0, "H"), (38, 1.0, "J"),
|
||||||
|
(40, 1.0, "K"), (37, 1.0, "L"), (41, 1.0, ";"), (39, 1.0, "'"),
|
||||||
|
(42, 1.0, "\\"), ("enter", 0.75, "⏎"),
|
||||||
|
],
|
||||||
|
# Row 3: Bottom row
|
||||||
|
[
|
||||||
|
(None, 1.25, "⇧"), (50, 1.0, "`"), (6, 1.0, "Z"), (7, 1.0, "X"),
|
||||||
|
(8, 1.0, "C"), (9, 1.0, "V"), (11, 1.0, "B"), (45, 1.0, "N"),
|
||||||
|
(46, 1.0, "M"), (43, 1.0, ","), (47, 1.0, "."), (44, 1.0, "/"),
|
||||||
|
(None, 2.25, "⇧"),
|
||||||
|
],
|
||||||
|
# Row 4: Modifier row
|
||||||
|
[
|
||||||
|
(None, 1.0, "fn"), (None, 1.0, "⌃"), (None, 1.0, "⌥"),
|
||||||
|
(None, 1.25, "⌘"), (None, 5.0, ""),
|
||||||
|
(None, 1.25, "⌘"), (None, 1.0, "⌥"),
|
||||||
|
("arrows", 3.0, ""),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
# Modifier layer indices
|
||||||
|
MOD_BASE = "0"
|
||||||
|
MOD_SHIFT = "1"
|
||||||
|
MOD_OPTION = "3"
|
||||||
|
MOD_OPTION_SHIFT = "4"
|
||||||
|
|
||||||
|
# Display layers: (modifier_index, color_rgb, position)
|
||||||
|
DISPLAY_LAYERS = [
|
||||||
|
(MOD_SHIFT, (0, 40, 170), "top_left"),
|
||||||
|
(MOD_OPTION_SHIFT, (120, 0, 120), "top_right"),
|
||||||
|
(MOD_BASE, (0, 0, 0), "bottom_left"),
|
||||||
|
(MOD_OPTION, (170, 0, 0), "bottom_right"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Layout dimensions (mm)
|
||||||
|
KU = 20 # key unit size
|
||||||
|
KEY_GAP = 1 # gap between keys
|
||||||
|
MARGIN = 10 # page margin
|
||||||
|
# 15 keys × 20mm = 300mm + 2×10mm margins = 320mm → custom page width
|
||||||
|
PAGE_W = 320
|
||||||
|
PAGE_H = 210 # A4 height
|
||||||
|
|
||||||
|
# Colors (RGB tuples)
|
||||||
|
C_KEY_BG = (242, 242, 242)
|
||||||
|
C_KEY_BORDER = (190, 190, 190)
|
||||||
|
C_DEAD_BG = (255, 238, 204)
|
||||||
|
C_MOD_BG = (225, 225, 230)
|
||||||
|
|
||||||
|
# Font paths (relative to project root)
|
||||||
|
FONT_DIR = Path(__file__).parent.parent / "fonts" / "iosevka"
|
||||||
|
FONT_REGULAR = FONT_DIR / "IosevkaFixed-Regular.ttf"
|
||||||
|
FONT_BOLD = FONT_DIR / "IosevkaFixed-Bold.ttf"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_char(c):
|
||||||
|
"""Format a character for display, handling control chars and blanks."""
|
||||||
|
if not c:
|
||||||
|
return ""
|
||||||
|
if len(c) == 1:
|
||||||
|
cp = ord(c)
|
||||||
|
if cp < 0x20:
|
||||||
|
return f"U+{cp:04X}"
|
||||||
|
if cp == 0x7F:
|
||||||
|
return ""
|
||||||
|
if cp == 0xA0:
|
||||||
|
return "NBSP"
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def get_key_info(data, mod_idx, code_str):
|
||||||
|
"""Get display character and dead-key status for a key."""
|
||||||
|
km = data["keyMaps"].get(mod_idx, {}).get("keys", {})
|
||||||
|
key_data = km.get(code_str, {})
|
||||||
|
|
||||||
|
dead_state = key_data.get("deadKey", "")
|
||||||
|
if dead_state:
|
||||||
|
terminator = data["deadKeys"].get(dead_state, {}).get("terminator", "")
|
||||||
|
return safe_char(terminator), True
|
||||||
|
|
||||||
|
return safe_char(key_data.get("output", "")), False
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutPDF(FPDF):
|
||||||
|
def __init__(self, layout_name):
|
||||||
|
super().__init__(orientation="L", unit="mm", format=(PAGE_H, PAGE_W))
|
||||||
|
self.layout_name = layout_name
|
||||||
|
self.set_auto_page_break(auto=False)
|
||||||
|
|
||||||
|
if not FONT_REGULAR.exists():
|
||||||
|
print(f"ERROR: font not found: {FONT_REGULAR}")
|
||||||
|
print("Run: scripts/download-fonts.sh or see fonts/README.md")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
self.add_font("Iosevka", "", str(FONT_REGULAR))
|
||||||
|
if FONT_BOLD.exists():
|
||||||
|
self.add_font("Iosevka", "B", str(FONT_BOLD))
|
||||||
|
|
||||||
|
def _font(self, size, bold=False):
|
||||||
|
self.set_font("Iosevka", "B" if bold else "", size)
|
||||||
|
|
||||||
|
def _color(self, rgb):
|
||||||
|
self.set_text_color(*rgb)
|
||||||
|
|
||||||
|
def generate(self, data):
|
||||||
|
"""Generate the full PDF for a layout."""
|
||||||
|
self._draw_keyboard_page(data)
|
||||||
|
self._draw_dead_key_pages(data)
|
||||||
|
|
||||||
|
def _draw_keyboard_page(self, data):
|
||||||
|
self.add_page()
|
||||||
|
|
||||||
|
# title
|
||||||
|
self._font(18, bold=True)
|
||||||
|
self._color((0, 0, 0))
|
||||||
|
self.set_xy(MARGIN, 7)
|
||||||
|
self.cell(0, 7, self.layout_name)
|
||||||
|
|
||||||
|
# legend
|
||||||
|
self._draw_legend()
|
||||||
|
|
||||||
|
# keyboard
|
||||||
|
kb_y = 24
|
||||||
|
for row_idx, row in enumerate(KEYBOARD_ROWS):
|
||||||
|
x = MARGIN
|
||||||
|
y = kb_y + row_idx * KU
|
||||||
|
for key_code, width, label in row:
|
||||||
|
kw = width * KU - KEY_GAP
|
||||||
|
kh = KU - KEY_GAP
|
||||||
|
|
||||||
|
if key_code == "spacer":
|
||||||
|
# invisible spacer — just advance x
|
||||||
|
pass
|
||||||
|
elif key_code == "enter":
|
||||||
|
# tall enter key spanning up into previous row
|
||||||
|
enter_h = 2 * KU - KEY_GAP
|
||||||
|
enter_y = y - KU
|
||||||
|
self._draw_mod_key(x, enter_y, kw, enter_h, label, key_code)
|
||||||
|
elif key_code == "arrows":
|
||||||
|
self._draw_arrow_cluster(x, y, kw, kh)
|
||||||
|
elif key_code is None or key_code not in TYPING_KEY_CODES:
|
||||||
|
self._draw_mod_key(x, y, kw, kh, label, key_code)
|
||||||
|
else:
|
||||||
|
self._draw_key(x, y, kw, kh, key_code, data)
|
||||||
|
|
||||||
|
x += width * KU
|
||||||
|
|
||||||
|
def _draw_legend(self):
|
||||||
|
y = 17
|
||||||
|
x = MARGIN
|
||||||
|
self._font(8)
|
||||||
|
items = [
|
||||||
|
((0, 0, 0), "Base"),
|
||||||
|
((0, 40, 170), "Shift"),
|
||||||
|
((170, 0, 0), "Option"),
|
||||||
|
((120, 0, 120), "Option+Shift"),
|
||||||
|
]
|
||||||
|
for color, label in items:
|
||||||
|
self._color(color)
|
||||||
|
self.set_xy(x, y)
|
||||||
|
self.cell(0, 4, f"■ {label}")
|
||||||
|
x += 26
|
||||||
|
|
||||||
|
# dead key indicator
|
||||||
|
self.set_fill_color(*C_DEAD_BG)
|
||||||
|
self.set_draw_color(*C_KEY_BORDER)
|
||||||
|
self.rect(x, y, 5, 3.5, "DF")
|
||||||
|
self._color((0, 0, 0))
|
||||||
|
self.set_xy(x + 6, y)
|
||||||
|
self.cell(0, 4, "Dead key")
|
||||||
|
|
||||||
|
def _draw_mod_key(self, x, y, w, h, label, key_code):
|
||||||
|
"""Draw a modifier/non-typing key."""
|
||||||
|
self.set_fill_color(*C_MOD_BG)
|
||||||
|
self.set_draw_color(*C_KEY_BORDER)
|
||||||
|
self.rect(x, y, w, h, "DF")
|
||||||
|
|
||||||
|
if label:
|
||||||
|
self._font(14, bold=True)
|
||||||
|
self._color((130, 130, 130))
|
||||||
|
self.set_xy(x, y + h / 2 - 3)
|
||||||
|
self.cell(w, 6, label, align="C")
|
||||||
|
|
||||||
|
def _draw_arrow_cluster(self, x, y, w, h):
|
||||||
|
"""Draw inverted-T arrow keys."""
|
||||||
|
arrow_w = w / 3 - KEY_GAP * 0.67
|
||||||
|
arrow_h = h / 2 - KEY_GAP * 0.5
|
||||||
|
arrows_top = [("", arrow_w), ("▲", arrow_w), ("", arrow_w)]
|
||||||
|
arrows_bot = [("◀", arrow_w), ("▼", arrow_w), ("▶", arrow_w)]
|
||||||
|
for row_arrows, ay in [(arrows_top, y), (arrows_bot, y + h / 2)]:
|
||||||
|
ax = x
|
||||||
|
for label, aw in row_arrows:
|
||||||
|
if label:
|
||||||
|
self.set_fill_color(*C_MOD_BG)
|
||||||
|
self.set_draw_color(*C_KEY_BORDER)
|
||||||
|
self.rect(ax, ay, aw, arrow_h, "DF")
|
||||||
|
self._font(7)
|
||||||
|
self._color((130, 130, 130))
|
||||||
|
self.set_xy(ax, ay + arrow_h / 2 - 2)
|
||||||
|
self.cell(aw, 4, label, align="C")
|
||||||
|
ax += aw + KEY_GAP
|
||||||
|
|
||||||
|
def _draw_key(self, x, y, w, h, key_code, data):
|
||||||
|
"""Draw a typing key with 4 modifier layers."""
|
||||||
|
code_str = str(key_code)
|
||||||
|
has_dead = False
|
||||||
|
|
||||||
|
# check if any layer is a dead key
|
||||||
|
for mod_idx, _, _ in DISPLAY_LAYERS:
|
||||||
|
_, is_dead = get_key_info(data, mod_idx, code_str)
|
||||||
|
if is_dead:
|
||||||
|
has_dead = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# background
|
||||||
|
bg = C_DEAD_BG if has_dead else C_KEY_BG
|
||||||
|
self.set_fill_color(*bg)
|
||||||
|
self.set_draw_color(*C_KEY_BORDER)
|
||||||
|
self.rect(x, y, w, h, "DF")
|
||||||
|
|
||||||
|
pad = 1.2
|
||||||
|
mid_x = x + w / 2
|
||||||
|
mid_y = y + h / 2
|
||||||
|
|
||||||
|
for mod_idx, color, pos in DISPLAY_LAYERS:
|
||||||
|
char, is_dead = get_key_info(data, mod_idx, code_str)
|
||||||
|
if not char:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._color(color)
|
||||||
|
|
||||||
|
if pos == "bottom_left":
|
||||||
|
self._font(14)
|
||||||
|
self.set_xy(x + pad, mid_y - 0.5)
|
||||||
|
self.cell(w / 2 - pad, h / 2, char)
|
||||||
|
elif pos == "top_left":
|
||||||
|
self._font(11)
|
||||||
|
self.set_xy(x + pad, y + pad)
|
||||||
|
self.cell(w / 2 - pad, 5.5, char)
|
||||||
|
elif pos == "bottom_right":
|
||||||
|
self._font(11)
|
||||||
|
self.set_xy(mid_x, mid_y - 0.5)
|
||||||
|
self.cell(w / 2 - pad, h / 2, char, align="R")
|
||||||
|
elif pos == "top_right":
|
||||||
|
self._font(11)
|
||||||
|
self.set_xy(mid_x, y + pad)
|
||||||
|
self.cell(w / 2 - pad, 5.5, char, align="R")
|
||||||
|
|
||||||
|
# catchy names keyed by terminator character (stable across versions)
|
||||||
|
DEAD_KEY_NAMES_BY_TERMINATOR = {
|
||||||
|
"´": "The Acutes",
|
||||||
|
"`": "The Graves",
|
||||||
|
"^": "The Circumflexes",
|
||||||
|
"~": "The Tildes",
|
||||||
|
"¨": "The Umlauts",
|
||||||
|
"ˇ": "The Háčeks",
|
||||||
|
"¯": "The Macrons",
|
||||||
|
"˚": "The Rings & Dots",
|
||||||
|
"α": "The Greeks",
|
||||||
|
"√": "The Mathematicians",
|
||||||
|
"¬": "The Navigators",
|
||||||
|
"©": "The Navigators",
|
||||||
|
" ": "The Mathematicians",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _find_dead_key_trigger(self, data, state_name):
|
||||||
|
"""Find which key combo triggers a dead key state."""
|
||||||
|
mod_names = {
|
||||||
|
"0": "", "1": "⇧ ", "2": "⇪ ", "3": "⌥ ",
|
||||||
|
"4": "⌥⇧ ", "5": "⇪⌥ ",
|
||||||
|
}
|
||||||
|
# map key codes to physical labels from KEYBOARD_ROWS
|
||||||
|
code_labels = {}
|
||||||
|
for row in KEYBOARD_ROWS:
|
||||||
|
for key_code, _, label in row:
|
||||||
|
if isinstance(key_code, int):
|
||||||
|
code_labels[str(key_code)] = label
|
||||||
|
for mod_idx, km in data["keyMaps"].items():
|
||||||
|
for kc, kd in km["keys"].items():
|
||||||
|
if kd.get("deadKey") == state_name:
|
||||||
|
prefix = mod_names.get(mod_idx, f"mod{mod_idx} ")
|
||||||
|
key_label = code_labels.get(kc, f"key{kc}")
|
||||||
|
return f"{prefix}{key_label}"
|
||||||
|
return state_name
|
||||||
|
|
||||||
|
def _draw_dead_key_pages(self, data):
|
||||||
|
"""Draw dead key compositions as full keyboard layouts."""
|
||||||
|
dead_keys = data.get("deadKeys", {})
|
||||||
|
if not dead_keys:
|
||||||
|
return
|
||||||
|
|
||||||
|
actions = data.get("actions", {})
|
||||||
|
|
||||||
|
for state_name in sorted(dead_keys.keys()):
|
||||||
|
dk = dead_keys[state_name]
|
||||||
|
terminator = dk.get("terminator", "")
|
||||||
|
compositions = dk.get("compositions", {})
|
||||||
|
if not compositions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# build char → composed lookup
|
||||||
|
char_to_composed = {}
|
||||||
|
for action_id, composed in compositions.items():
|
||||||
|
base = actions.get(action_id, {}).get("none", "")
|
||||||
|
if base:
|
||||||
|
char_to_composed[base] = composed
|
||||||
|
|
||||||
|
# skip if no compositions mapped
|
||||||
|
if not char_to_composed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
trigger = self._find_dead_key_trigger(data, state_name)
|
||||||
|
catchy = self.DEAD_KEY_NAMES_BY_TERMINATOR.get(terminator, state_name)
|
||||||
|
self.add_page()
|
||||||
|
|
||||||
|
# title
|
||||||
|
self._font(18, bold=True)
|
||||||
|
self._color((0, 0, 0))
|
||||||
|
self.set_xy(MARGIN, 7)
|
||||||
|
self.cell(0, 7, f"{self.layout_name} — {catchy} ({trigger}, terminator: {safe_char(terminator)})")
|
||||||
|
|
||||||
|
# draw keyboard with compositions
|
||||||
|
kb_y = 24
|
||||||
|
for row_idx, row in enumerate(KEYBOARD_ROWS):
|
||||||
|
x = MARGIN
|
||||||
|
y = kb_y + row_idx * KU
|
||||||
|
for key_code, width, label in row:
|
||||||
|
kw = width * KU - KEY_GAP
|
||||||
|
kh = KU - KEY_GAP
|
||||||
|
|
||||||
|
if key_code == "spacer":
|
||||||
|
pass
|
||||||
|
elif key_code == "enter":
|
||||||
|
enter_h = 2 * KU - KEY_GAP
|
||||||
|
enter_y = y - KU
|
||||||
|
self._draw_mod_key(x, enter_y, kw, enter_h, label, key_code)
|
||||||
|
elif key_code == "arrows":
|
||||||
|
self._draw_arrow_cluster(x, y, kw, kh)
|
||||||
|
elif not isinstance(key_code, int) or key_code not in TYPING_KEY_CODES:
|
||||||
|
self._draw_mod_key(x, y, kw, kh, label, key_code)
|
||||||
|
else:
|
||||||
|
self._draw_dead_composition_key(
|
||||||
|
x, y, kw, kh, key_code, data, char_to_composed,
|
||||||
|
)
|
||||||
|
|
||||||
|
x += width * KU
|
||||||
|
|
||||||
|
def _draw_dead_composition_key(self, x, y, w, h, key_code, data, char_to_composed):
|
||||||
|
"""Draw a key showing dead key compositions for base and shift layers."""
|
||||||
|
code_str = str(key_code)
|
||||||
|
km_base = data["keyMaps"].get(MOD_BASE, {}).get("keys", {})
|
||||||
|
km_shift = data["keyMaps"].get(MOD_SHIFT, {}).get("keys", {})
|
||||||
|
|
||||||
|
base_char = km_base.get(code_str, {}).get("output", "")
|
||||||
|
shift_char = km_shift.get(code_str, {}).get("output", "")
|
||||||
|
|
||||||
|
base_composed = char_to_composed.get(base_char, "")
|
||||||
|
shift_composed = char_to_composed.get(shift_char, "")
|
||||||
|
|
||||||
|
# background — highlight if any composition exists
|
||||||
|
has_comp = bool(base_composed or shift_composed)
|
||||||
|
bg = C_DEAD_BG if has_comp else C_KEY_BG
|
||||||
|
self.set_fill_color(*bg)
|
||||||
|
self.set_draw_color(*C_KEY_BORDER)
|
||||||
|
self.rect(x, y, w, h, "DF")
|
||||||
|
|
||||||
|
pad = 1.2
|
||||||
|
mid_y = y + h / 2
|
||||||
|
|
||||||
|
if base_composed:
|
||||||
|
self._font(14)
|
||||||
|
self._color((0, 0, 0))
|
||||||
|
self.set_xy(x + pad, mid_y - 0.5)
|
||||||
|
self.cell(w / 2 - pad, h / 2, safe_char(base_composed))
|
||||||
|
elif base_char:
|
||||||
|
self._font(14)
|
||||||
|
self._color((200, 200, 200))
|
||||||
|
self.set_xy(x + pad, mid_y - 0.5)
|
||||||
|
self.cell(w / 2 - pad, h / 2, safe_char(base_char))
|
||||||
|
|
||||||
|
if shift_composed:
|
||||||
|
self._font(11)
|
||||||
|
self._color((0, 40, 170))
|
||||||
|
self.set_xy(x + pad, y + pad)
|
||||||
|
self.cell(w / 2 - pad, 5.5, safe_char(shift_composed))
|
||||||
|
elif shift_char:
|
||||||
|
self._font(11)
|
||||||
|
self._color((200, 200, 200))
|
||||||
|
self.set_xy(x + pad, y + pad)
|
||||||
|
self.cell(w / 2 - pad, 5.5, safe_char(shift_char))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_pdf(version, output_dir):
|
||||||
|
"""Generate a PDF for the given layout version."""
|
||||||
|
src_dir = Path(__file__).parent.parent / "src" / "keylayouts"
|
||||||
|
keylayout = src_dir / f"EurKEY {version}.keylayout"
|
||||||
|
|
||||||
|
if not keylayout.exists():
|
||||||
|
print(f"ERROR: {keylayout} not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"Generating PDF for EurKEY {version}...")
|
||||||
|
data = parse_keylayout(str(keylayout), keyboard_type=0)
|
||||||
|
|
||||||
|
pdf = LayoutPDF(f"EurKEY {version}")
|
||||||
|
pdf.generate(data)
|
||||||
|
|
||||||
|
out = Path(output_dir)
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path = out / f"eurkey-{version}-layout.pdf"
|
||||||
|
pdf.output(str(output_path))
|
||||||
|
print(f" Written: {output_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Generate keyboard layout PDFs")
|
||||||
|
parser.add_argument(
|
||||||
|
"--version", "-v", nargs="*",
|
||||||
|
default=["v1.2", "v1.3", "v1.4", "v2.0"],
|
||||||
|
help="Layout versions to generate (default: all)",
|
||||||
|
)
|
||||||
|
default_output = str(Path(__file__).parent.parent / "build")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", "-o", default=default_output,
|
||||||
|
help="Output directory (default: build/)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
success = True
|
||||||
|
for version in args.version:
|
||||||
|
if not generate_pdf(version, args.output):
|
||||||
|
success = False
|
||||||
|
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
#!/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: "Option+Shift",
|
||||||
|
5: "Caps+Option",
|
||||||
|
6: "Option+Command",
|
||||||
|
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, keyboard_type=0):
|
||||||
|
"""Parse a .keylayout XML file and return a structured dict.
|
||||||
|
|
||||||
|
keyboard_type selects which mapSet to use. Each <layout> element
|
||||||
|
covers a range of hardware keyboard types (first..last). The mapSet
|
||||||
|
matching the requested type is used. Default 0 = MacBook built-in.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
# find the mapSet for the requested keyboard type
|
||||||
|
target_map_set = None
|
||||||
|
for layout in root.findall(".//layout"):
|
||||||
|
first = int(layout.get("first", "0"))
|
||||||
|
last = int(layout.get("last", "0"))
|
||||||
|
if first <= keyboard_type <= last:
|
||||||
|
target_map_set = layout.get("mapSet")
|
||||||
|
break
|
||||||
|
|
||||||
|
if target_map_set is None:
|
||||||
|
# fall back to first layout entry
|
||||||
|
first_layout = root.find(".//layout")
|
||||||
|
target_map_set = first_layout.get("mapSet") if first_layout is not None else None
|
||||||
|
|
||||||
|
# resolve keys from the selected mapSet
|
||||||
|
resolved = {}
|
||||||
|
kms = key_map_sets.get(target_map_set, {})
|
||||||
|
for idx_str, keys in kms.items():
|
||||||
|
resolved[idx_str] = dict(keys)
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
parser.add_argument("--keyboard-type", "-k", type=int, default=0,
|
||||||
|
help="Hardware keyboard type ID (default: 0 = MacBook built-in)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
data = parse_keylayout(args.keylayout, keyboard_type=args.keyboard_type)
|
||||||
|
|
||||||
|
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,312 @@
|
|||||||
|
#!/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 / "build" / "EurKey-macOS.bundle" / "Contents" / "Resources"
|
||||||
|
|
||||||
|
# modifier indices that contain meaningful typing output
|
||||||
|
# (exclude index 6 = Option+Command 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 = {
|
||||||
|
# Option+Shift 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
|
||||||
|
# instead, Option+- has the © dead key, and Option+\ outputs plain ¬
|
||||||
|
"_dead_key_skip": ["dead: ¬"],
|
||||||
|
"3:27": {"deadKey": "dead: ©"}, # Option+-: © dead key instead of ¬ dead key
|
||||||
|
"3:42": {"output": "¬"}, # Option+\: plain ¬ instead of ¬ dead key
|
||||||
|
"4:27": {"output": "№"}, # Option+Shift+-: № instead of ✗
|
||||||
|
"5:27": {"deadKey": "dead: ©"}, # Caps+Option+-: © dead key instead of ¬ dead key
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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: §/` → ẞ
|
||||||
|
"5:27": {"output": ""}, # Caps+Option+-: no output (missing ¬ dead key in this layer)
|
||||||
|
"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()
|
||||||