Compare commits
25 Commits
b3cfa280df
...
2026.03.22
| Author | SHA1 | Date | |
|---|---|---|---|
| ea4f88ae51 | |||
| c2274406ec | |||
| 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-Next.bundle/**'
|
||||
- 'scripts/**'
|
||||
- 'spec/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'EurKEY-Next.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-Next-DMG
|
||||
path: build/*.dmg
|
||||
@@ -0,0 +1,87 @@
|
||||
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 Next ${{ steps.version.outputs.version }}
|
||||
files: build/EurKEY-Next-${{ steps.version.outputs.version }}.dmg
|
||||
body: |
|
||||
## EurKEY Next
|
||||
|
||||
- **ANSI compatibility:** the ISO-only key between left Shift and Z does not exist on ANSI keyboards. Its characters § and ± are now accessible via the `⌥\` dead key (`⌥\` `s` → §, `⌥\` `S` → ±).
|
||||
- Normalize Caps Lock: Caps Lock = Shift, no special bindings.
|
||||
- Configure every key exactly as printed on the MacBook keyboard (ISO, English International).
|
||||
- Remove distinction between left/right modifier keys.
|
||||
- Rename all dead key states to their initializing key combination for easier identification.
|
||||
- Fix Greek dead key terminator (Ω → α) to match official EurKEY spec.
|
||||
- Fix modifier key order to Apple canonical: Option+Shift (not Shift+Option).
|
||||
- Clean up duplicate dead key compositions.
|
||||
- Change Mathematicians (`⌥⇧m`) dead key terminator from invisible space to 𝕄.
|
||||
|
||||
## v1.4
|
||||
|
||||
- Normalize Caps Lock: Caps Lock = Shift, no special bindings.
|
||||
- Fix accidental Caps Lock changes from previous release.
|
||||
|
||||
## v1.3
|
||||
|
||||
- Normalize Caps Lock: Caps Lock = Shift, no special bindings.
|
||||
|
||||
## v1.2
|
||||
|
||||
- Normalize Caps Lock: Caps Lock = Shift, no special bindings.
|
||||
|
||||
## Project
|
||||
|
||||
- Rename v2.0 → EurKEY Next.
|
||||
- Rename bundle to `EurKEY-Next.bundle`.
|
||||
- New lightweight static website at eurkey-macos.eu.
|
||||
- Interactive keyboard viewer with modifier layer preview and dead key compositions.
|
||||
- Layout PDF downloads.
|
||||
- New monochrome macOS template icon.
|
||||
- Restructure project with automated build, validation, and release pipelines.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download `EurKEY-Next-${{ steps.version.outputs.version }}.dmg`
|
||||
2. Open the DMG
|
||||
3. Drag `EurKEY-Next.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 Next
|
||||
generate_release_notes: false
|
||||
@@ -0,0 +1,52 @@
|
||||
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
|
||||
python3 scripts/parse_keylayout.py "src/keylayouts/EurKEY Next.keylayout" \
|
||||
-o "eurkey-macos.eu/data/eurkey-next.json"
|
||||
for ver in v1.2 v1.3 v1.4; 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 |
@@ -1,23 +1,100 @@
|
||||
EurKEY-Mac
|
||||
==========
|
||||
# EurKEY Next
|
||||
|
||||
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 Next works with both ISO and ANSI keyboards. It targets the physical English International keyboard found on European MacBooks (ISO) but is fully compatible with US ANSI keyboards. The ISO key between left Shift and Z (**`§`**/**`±`**) does not exist on ANSI — these characters are accessible via the **`⌥\`** dead key: **`⌥\`** **`s`** → §, **`⌥\`** **`S`** → ±.
|
||||
|
||||
**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 |
|
||||
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **EurKEY Next** | Every key configures exactly as printed on the MacBook keyboard. Removes left/right modifier key distinction. Monochrome template icon. |
|
||||
| **v1.4** | v1.3 with swapped super/subscript numbers: **`⌥m`** **`1`**…**`0`** produces subscript (₁…₀), **`⌥m`** **`⇧1`**…**`⇧0`** produces superscript (¹…⁰). |
|
||||
| **v1.3** | Official EurKEY spec implementation. |
|
||||
| **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 **`⌥⇧S`**). |
|
||||
|
||||
## Installation
|
||||
|
||||
License
|
||||
=======
|
||||
### From DMG
|
||||
|
||||
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-Next-YYYY.MM.DD.dmg` from [Releases](https://github.com/felixfoertsch/EurKEY-macOS/releases).
|
||||
2. Open the DMG.
|
||||
3. Drag `EurKEY-Next.bundle` to the `Install Here (Keyboard Layouts)` folder.
|
||||
4. Log out and back in (or restart).
|
||||
5. System Settings → Keyboard → Input Sources → click `+` → select EurKEY Next.
|
||||
|
||||
### Manual
|
||||
|
||||
1. Download or clone this repo.
|
||||
2. Copy `EurKEY-Next.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 Next.
|
||||
|
||||
<img src="eurkey-macos.eu/img/1-input-sources.png" width="300" alt="System preferences showing the edit button for input sources.">
|
||||
<img src="eurkey-macos.eu/img/2-add-layout.png" width="300" alt="Dialogue to add a new input source.">
|
||||
<img src="eurkey-macos.eu/img/3-select-eurkey.png" width="300" alt="EurKEY Next in the input sources list.">
|
||||
<img src="eurkey-macos.eu/img/4-select-input-method.png" width="300" alt="Selecting EurKEY Next 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-Next.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 (EurKEY Next)
|
||||
|
||||
EurKEY Next renames all dead key states to their initializing key combination:
|
||||
|
||||
| Key combination | Dead key symbol |
|
||||
| --------------- | --------------- |
|
||||
| **`` ⌥` ``** | `` ` `` |
|
||||
| **`` ⌥⇧` ``** | ~ |
|
||||
| **`⌥'`** | ´ |
|
||||
| **`⌥⇧'`** | ¨ |
|
||||
| **`⌥6`** | ^ |
|
||||
| **`⌥⇧6`** | ˇ |
|
||||
| **`⌥7`** | ˚ |
|
||||
| **`⌥⇧7`** | ¯ |
|
||||
| **`⌥m`** | α |
|
||||
| **`⌥⇧m`** | 𝕄 |
|
||||
| **`⌥\`** | ¬ |
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 Next — The European Keyboard Layout for macOS</title>
|
||||
<meta name="description" content="The keyboard layout for Europeans, coders, and translators. Dead keys for diacritics, ISO and ANSI compatible, 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 Next</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 Next — 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="next">EurKEY Next</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-next-layout.pdf" id="pdf-download" class="btn btn--secondary">Download EurKEY Next 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<br>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 + ANSI<br>Compatible</h3>
|
||||
<p>Built for ISO MacBooks, fully compatible with ANSI keyboards. All characters remain accessible via dead keys.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon"><img src="img/icon-versions.svg" alt="" width="36" height="36"></div>
|
||||
<h3>Ships Multiple<br>Versions</h3>
|
||||
<p>Ships EurKEY Next, v1.4, v1.3, and v1.2 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>Packaged for<br>Easy Installation</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-Next.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 Next" and select it.</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 Next 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 Next.</p>
|
||||
<div class="install-screenshots">
|
||||
<img src="img/4-select-input-method.png" alt="Selecting EurKEY Next from the menu bar dropdown" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>EurKEY Next 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,461 @@
|
||||
/* 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 = "next";
|
||||
let currentData = null;
|
||||
let currentDeadKey = null;
|
||||
let keyElements = new Map();
|
||||
|
||||
async function loadVersion(version) {
|
||||
if (cache.has(version)) return cache.get(version);
|
||||
const file = "data/eurkey-" + version + ".json";
|
||||
const resp = await fetch(file);
|
||||
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";
|
||||
const label = currentVersion === "next" ? "EurKEY Next" : currentVersion;
|
||||
link.textContent = "Download " + label + " 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,658 @@
|
||||
/* 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;
|
||||
min-height: 3.2em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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,138 @@
|
||||
#!/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-Next.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-Next"
|
||||
BUNDLE_NAME="EurKEY-Next"
|
||||
|
||||
# layout names to include (EurKEY Next is the main layout, others are legacy versions)
|
||||
LAYOUTS=("EurKEY Next" "EurKEY v1.4" "EurKEY v1.3" "EurKEY v1.2")
|
||||
|
||||
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 layout in "${LAYOUTS[@]}"; do
|
||||
cp "${SRC_DIR}/keylayouts/${layout}.keylayout" "${RESOURCES_DIR}/"
|
||||
cp "${SRC_DIR}/icons/${layout}.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 layout
|
||||
for layout in "${LAYOUTS[@]}"; do
|
||||
# generate input source ID: bundle id + layout name lowercased, spaces removed
|
||||
source_id_suffix=$(echo "${layout}" | tr '[:upper:]' '[:lower:]' | tr -d ' ')
|
||||
source_id="${BUNDLE_ID}.${source_id_suffix}"
|
||||
|
||||
cat >> "${CONTENTS_DIR}/Info.plist" << KLINFO_ENTRY
|
||||
<key>KLInfo_${layout}</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 ${#LAYOUTS[@]} 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-Next.bundle"
|
||||
|
||||
# parse arguments
|
||||
VERSION="${1:-$(date +%Y.%m.%d)}"
|
||||
if [[ "${1:-}" == "--version" ]]; then
|
||||
VERSION="${2:?missing version argument}"
|
||||
fi
|
||||
|
||||
DMG_NAME="EurKEY-Next-${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 EurKEY Next 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 (EurKEY Next 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,490 @@
|
||||
#!/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"
|
||||
display_name = f"EurKEY {version}"
|
||||
pdf_name = f"eurkey-{version.lower()}-layout.pdf"
|
||||
|
||||
if not keylayout.exists():
|
||||
print(f"ERROR: {keylayout} not found")
|
||||
return False
|
||||
|
||||
print(f"Generating PDF for {display_name}...")
|
||||
data = parse_keylayout(str(keylayout), keyboard_type=0)
|
||||
|
||||
pdf = LayoutPDF(display_name)
|
||||
pdf.generate(data)
|
||||
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
output_path = out / pdf_name
|
||||
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=["Next", "v1.4", "v1.3", "v1.2"],
|
||||
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,310 @@
|
||||
#!/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-Next.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": "§"},
|
||||
"5:1": {"output": "§"}, # Caps+Option S: mirrors Option+Shift (§)
|
||||
# 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": {"output": "№"}, # Caps+Option+-: mirrors Option+Shift (№)
|
||||
}
|
||||
|
||||
# v1.4 differences from v1.3:
|
||||
# - swaps super/subscript numbers in α dead key
|
||||
# - ¬ dead key has an extra ¬ composition (self-referencing, harmless Ukelele artifact)
|
||||
V1_4_EXCEPTIONS = {
|
||||
"dead:dead: ¬:extra:¬": True, # extra ¬ composition in negation dead key
|
||||
}
|
||||
|
||||
# EurKEY Next (main edition) — skip validation, custom layout
|
||||
EURKEY_NEXT_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"},
|
||||
"next": {"file": "Next", "exceptions": EURKEY_NEXT_EXCEPTIONS, "label": "EurKEY Next"},
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||