Compare commits
23 Commits
b3cfa280df
...
2026.03.16
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c7e54754e | |||
| 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 |
|
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).
|
||||
The EU flag icon is taken from [Iconspedia](http://www.iconspedia.com/pack/european-flags-1631/),
|
||||
created by [Alpak](http://alpak.deviantart.com/) and
|
||||
licensed under [CC](http://creativecommons.org/licenses/by-nc-nd/3.0)
|
||||
1. Download the latest `EurKEY-macOS-YYYY.MM.DD.dmg` from [Releases](https://github.com/felixfoertsch/EurKEY-macOS/releases).
|
||||
2. Open the DMG.
|
||||
3. Drag `EurKey-macOS.bundle` to the `Install Here (Keyboard Layouts)` folder.
|
||||
4. Log out and back in (or restart).
|
||||
5. System Settings → Keyboard → Input Sources → click `+` → select the EurKEY version you want.
|
||||
|
||||
### Manual
|
||||
|
||||
1. Download or clone this repo.
|
||||
2. Copy `EurKey-macOS.bundle` to `/Library/Keyboard Layouts/` (system-wide) or `~/Library/Keyboard Layouts/` (user-only).
|
||||
3. Log out and back in.
|
||||
4. System Settings → Keyboard → Input Sources → click `+` → select EurKEY.
|
||||
|
||||
<img src="eurkey-macos.eu/static/img/1-input-sources.png" width="300" alt="System preferences showing the edit button for input sources.">
|
||||
<img src="eurkey-macos.eu/static/img/2-add-layout.png" width="300" alt="Dialogue to add a new input source.">
|
||||
<img src="eurkey-macos.eu/static/img/3-select-eurkey.png" width="300" alt="EurKEY in the input sources list.">
|
||||
<img src="eurkey-macos.eu/static/img/4-select-input-method.png" width="300" alt="Selecting EurKEY from the menu bar dropdown.">
|
||||
|
||||
## Validation
|
||||
|
||||
The project includes automated validation to catch regressions. The validation script parses each `.keylayout` XML file and compares key mappings and dead key compositions against the v1.3 reference.
|
||||
|
||||
```bash
|
||||
# validate all layouts
|
||||
python3 scripts/validate_layouts.py
|
||||
|
||||
# parse a single layout to JSON
|
||||
python3 scripts/parse_keylayout.py "EurKey-macOS.bundle/Contents/Resources/EurKEY v1.3.keylayout" --summary
|
||||
|
||||
# build the bundle (validates + generates Info.plist)
|
||||
bash scripts/build-bundle.sh
|
||||
|
||||
# create a DMG installer
|
||||
bash scripts/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,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<!-- Key cap with accent mark -->
|
||||
<rect x="8" y="12" width="32" height="28" rx="4" stroke="#003399" stroke-width="2.5" fill="none"/>
|
||||
<!-- Letter a on key -->
|
||||
<text x="24" y="35" text-anchor="middle" font-family="-apple-system, system-ui, sans-serif" font-size="16" font-weight="700" fill="#003399">a</text>
|
||||
<!-- Accent mark floating above key -->
|
||||
<path d="M20 8 L24 4 L28 8" stroke="#003399" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 558 B |
@@ -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,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<!-- ISO Enter key shape (inverted L) -->
|
||||
<path d="M18 6 H38 Q42 6 42 10 V38 Q42 42 38 42 H26 Q22 42 22 38 V22 H10 Q6 22 6 18 V10 Q6 6 10 6 Z" stroke="#003399" stroke-width="2.5" fill="none"/>
|
||||
<!-- Enter arrow -->
|
||||
<path d="M36 18 V30 H28" stroke="#003399" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31 27 L28 30 L31 33" stroke="#003399" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 521 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"><img src="img/icon-deadkeys.svg" alt="" width="36" height="36"></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"><img src="img/icon-iso.svg" alt="" width="36" height="36"></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"><img src="img/icon-versions.svg" alt="" width="36" height="36"></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"><img src="img/icon-install.svg" alt="" width="36" height="36"></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,459 @@
|
||||
/* 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",
|
||||
"\uD835\uDD44": "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);
|
||||
|
||||
const deadStates = [];
|
||||
|
||||
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) {
|
||||
if (!deadStates.includes(info.deadKey)) deadStates.push(info.deadKey);
|
||||
span.classList.add("key-char--is-dead");
|
||||
}
|
||||
}
|
||||
keyEl.appendChild(span);
|
||||
}
|
||||
|
||||
if (deadStates.length > 0) {
|
||||
deadStates.reverse();
|
||||
keyEl.classList.add("key--dead");
|
||||
keyEl.dataset.deadKey = deadStates[0];
|
||||
keyEl.dataset.deadKeys = JSON.stringify(deadStates);
|
||||
keyEl.addEventListener("click", () => cycleDeadKeyMode(keyEl));
|
||||
}
|
||||
}
|
||||
|
||||
rowEl.appendChild(keyEl);
|
||||
}
|
||||
|
||||
kb.appendChild(rowEl);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Dead key mode --- */
|
||||
|
||||
function toggleDeadKeyMode(deadState) {
|
||||
if (currentDeadKey === deadState) {
|
||||
exitDeadKeyMode();
|
||||
} else {
|
||||
enterDeadKeyMode(deadState);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleDeadKeyMode(keyEl) {
|
||||
const deadStates = JSON.parse(keyEl.dataset.deadKeys || "[]");
|
||||
if (deadStates.length === 0) return;
|
||||
|
||||
if (!currentDeadKey || !deadStates.includes(currentDeadKey)) {
|
||||
enterDeadKeyMode(deadStates[0]);
|
||||
} else {
|
||||
const idx = deadStates.indexOf(currentDeadKey);
|
||||
const next = idx + 1;
|
||||
if (next < deadStates.length) {
|
||||
enterDeadKeyMode(deadStates[next]);
|
||||
} else {
|
||||
exitDeadKeyMode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 allDead = JSON.parse(keyEl.dataset.deadKeys || "[]");
|
||||
if (allDead.includes(deadState)) {
|
||||
keyEl.classList.add("key--dead-active");
|
||||
// only show the span for the layer that owns this dead key
|
||||
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);
|
||||
if (info?.deadKey === deadState) {
|
||||
spans[i].style.visibility = "visible";
|
||||
} else {
|
||||
spans[i].style.visibility = "hidden";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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 (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) : "";
|
||||
spans[i].style.visibility = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||