26 Commits

Author SHA1 Message Date
felixfoertsch c99483ee46 switch GoatCounter to dedicated eurkey-next instance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:44:32 +02:00
felixfoertsch ea4f88ae51 rename v2.0 to EurKEY Next, add ANSI support, normalize Caps Lock, fix v1.4
- rename v2.0 → EurKEY Next across all files, scripts, workflows, website
- rename bundle EurKey-macOS → EurKEY-macOS
- add §/± to ¬ dead key (¬+s → §, ¬+S → ±) for ANSI keyboard compatibility
- normalize Caps Lock = Shift across all four layouts, remove custom Caps bindings
- fix v1.4: revert accidental Caps/ẞ changes, correct changelog to super/subscript swap
- update README: remove duplicate v1.2–v1.4 changelog, reorder versions (Next first)
- update website: ISO+ANSI feature card, EurKEY Next branding, consistent version order

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:33:23 +01:00
felixfoertsch c2274406ec update readme: fix image paths, add changelog, remove stale bundle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:39:31 +01:00
felixfoertsch 7c7e54754e update v2.0 layout, fix keyboard viewer dead key cycling, clean up repo
- remove duplicate compositions from v2.0: § from Navigators, ± from
  Option+Shift §, ± from Mathematicians on -, terminator dupes on 5
- change Mathematicians terminator from space to 𝕄
- fix keyboard viewer: support multiple dead keys per key with click
  cycling, show active dead key's own layer character, hide others
- add feature card SVG icons (dead keys, ISO enter, versions, install)
- delete unused draft SVGs, remove leftover docs/ PDFs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:31:57 +01:00
felixfoertsch c5cd3850c3 fix release workflow: add python setup, reorder build before validate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 11:05:50 +01:00
felixfoertsch 92422e1bf1 fix modifier key order, add EU badge template icons, rename build scripts
- fix modifier key order to Apple canonical: Option+Shift (not Shift+Option)
  across parser, validator, PDF generator, website keyboard viewer, README
- add EU badge template icons for v1.2/v1.3/v1.4 matching Apple's built-in
  keyboard layout icon style (edge-to-edge rounded square, text knockout)
- add build-icons.sh to generate .icns from SVG source via rsvg-convert
- rename create-dmg.sh → build-dmg.sh, update CI workflows
- add website feature icons (install, pdf, versions)
- update website icon to star-on-key design

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 11:01:56 +01:00
felixfoertsch 1b9ce5dd86 add GoatCounter analytics, update footer with maintainer credit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:33:44 +01:00
felixfoertsch a78c45591f replace dead key panel with inline keyboard compositions
clicking a dead key or pressing its key combo (e.g. Option+M) now
shows compositions directly on the keyboard. keys with compositions
are highlighted, others dimmed. spacebar shows catchy group name.
click the active dead key again or press Escape to exit. add helper
text beneath keyboard explaining viewer interaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:10:48 +01:00
felixfoertsch f3c793c37f fix build workflow: add setup-python, reorder steps
add actions/setup-python for fpdf2 dependency (PEP 668 blocks bare
pip install on macOS runners). run bundle build before validation
so keylayout files exist when validator runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:44:19 +01:00
felixfoertsch fa864b65bc rebuild website, PDF generator to match keyboard viewer
remove hero section, add download button in nav, version-aware PDF
download button. rename section title. rebuild ISO enter as single
tall key spanning from row 3 upward. rebuild PDF generator: match
viewer layout (ISO enter, fn key, correct sizes, arrow cluster,
symbol labels), larger fonts, bold modifier labels. dead key pages
now render full keyboards with base/shift compositions, catchy group
names matched by terminator character.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:40:48 +01:00
felixfoertsch 18718e3424 add interactive keyboard viewer, fix parser, move spec PDF
interactive layout viewer with version tabs, modifier key highlighting,
dead key compositions, ISO enter spanning two rows, arrow cluster.
fix keylayout parser mapSet range handling, update PDF build scripts,
move eurkey-layout-complete.pdf to spec/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:49:36 +01:00
felixfoertsch a8f5cf4097 replace PDF iframe with download links, remove main.js 2026-03-05 14:23:58 +01:00
felixfoertsch 57c8d56afa add workflow_dispatch trigger to website deployment 2026-03-05 14:21:47 +01:00
felixfoertsch f8379103d2 trigger website deployment 2026-03-05 14:21:10 +01:00
felixfoertsch a31a682f24 remove windows binaries, add eurkey spec PDFs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:16:59 +01:00
felixfoertsch 234b29391f update scripts to use src/ keylayouts, add build-pdf.sh wrapper 2026-03-05 14:16:58 +01:00
felixfoertsch 3e2fc8f0d9 track src/, fonts/ as project source, replace Hugo with static website
- commit keylayouts, icons, lproj, Iosevka fonts
- remove Hugo, build static site (HTML + CSS + vanilla JS)
- CI workflow generates layout PDFs, deploys to GitHub Pages
2026-03-05 14:16:58 +01:00
felixfoertsch 019075c12c add .worktrees/ to gitignore 2026-03-05 14:16:58 +01:00
felixfoertsch 0e1d8c4a85 add layout PDF generation script, generate PDFs for all versions (closes #7)
- add scripts/generate_layout_pdf.py (requires fpdf2)
- generates ISO keyboard diagrams with Base, Shift, Option, Shift+Option
  layers per key, plus dead key composition tables
- output: docs/eurkey-{version}-layout.pdf for v1.2, v1.3, v1.4, v2.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:00:00 +01:00
felixfoertsch 9592b321b1 fix Greek dead key terminator (Ω→α), enable CapsLock language switch, fix parser key resolution
- change Greek dead key terminator from Ω to α in all layout versions,
  matching the official EurKEY spec (closes #11)
- change dead key state name from "dead: Ω" to "dead: α" in v1.2/v1.3
- enable TICapsLockLanguageSwitchCapable in build script (closes #8)
- fix parser to resolve all key codes from base keyMapSets, not just
  codes in explicit layout entry ranges
- add per-version exceptions for newly-discovered v1.2/v1.4 differences
- add Karabiner-Elements docs section, known issues section to README
- add Windows spec installers for reference
- regenerate all parsed JSON specs with fixed parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:59:46 +01:00
felixfoertsch 859c26f64c add build workflow, remove redundant validate workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:07:50 +01:00
felixfoertsch 5e29ef63d4 rewrite README: fix typos, add versions table, validation docs
- fix "verison" → "version", "pyhiscal" → "physical", "distiction" → "distinction"
- add versions table explaining each layout version
- add installation from DMG section
- add validation section with usage examples
- restructure changelog with clearer per-version descriptions
- fix dead key table formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:06:48 +01:00
felixfoertsch 079ff0a872 add build, DMG creation scripts, CI/CD workflows
- build-bundle.sh: regenerates Info.plist with KLInfo entries for all 4
  layout versions, sets CalVer bundle version, validates plists
- create-dmg.sh: packages bundle into a DMG with drag-and-drop symlink
  to /Library/Keyboard Layouts/
- release.yml: GitHub Actions workflow that validates, builds, creates DMG,
  publishes GitHub Release on CalVer tag push
- validate.yml: CI validation on push/PR for layout and script changes
- Info.plist now declares all 4 layouts (was only v2.0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:06:48 +01:00
felixfoertsch 7084817dab add validation infrastructure, fix "6" key bug in v1.3
- add keylayout XML parser (parse_keylayout.py) that extracts all key
  mappings, dead key compositions, modifier layers from .keylayout files
- add validation script (validate_layouts.py) that compares layouts against
  v1.3 reference with per-version exception support
- fix action id="6" outputting "p" instead of "6" in v1.3
- generate reference JSON for all 4 layout versions
- document known intentional differences: v1.2 (no ¬ dead key, § vs ẞ),
  v1.4 (ẞ on §/` caps, extra ¬ composition)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:06:48 +01:00
felixfoertsch e16b92051d add v1.3 to the bundle, implements official spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:06:48 +01:00
felixfoertsch b5e2de535e create bundle, add v1.4, v2.0 to the bundle
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:06:48 +01:00
55 changed files with 33782 additions and 1781 deletions
+44
View File
@@ -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
+87
View File
@@ -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
+52
View File
@@ -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
+38
View File
@@ -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

Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

BIN
View File
Binary file not shown.
-1767
View File
File diff suppressed because it is too large Load Diff
+91 -14
View File
@@ -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). 1. Download the latest `EurKEY-Next-YYYY.MM.DD.dmg` from [Releases](https://github.com/felixfoertsch/EurKEY-macOS/releases).
The EU flag icon is taken from [Iconspedia](http://www.iconspedia.com/pack/european-flags-1631/), 2. Open the DMG.
created by [Alpak](http://alpak.deviantart.com/) and 3. Drag `EurKEY-Next.bundle` to the `Install Here (Keyboard Layouts)` folder.
licensed under [CC](http://creativecommons.org/licenses/by-nc-nd/3.0) 4. Log out and back in (or restart).
5. System Settings → Keyboard → Input Sources → click `+` → select 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).
+1
View File
@@ -0,0 +1 @@
eurkey-macos.eu
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+8
View File
@@ -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

+9
View File
@@ -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

+7
View File
@@ -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

+12
View File
@@ -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

+13
View File
@@ -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

+6
View File
@@ -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

+141
View File
@@ -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://eurkey-next.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</body>
</html>
+461
View File
@@ -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.");
});
+658
View File
@@ -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;
}
}
+12
View File
@@ -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
Binary file not shown.
Binary file not shown.
+99
View File
@@ -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.
+138
View File
@@ -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}"
+68
View File
@@ -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)"
+66
View File
@@ -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"
+10
View File
@@ -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" "$@"
+490
View File
@@ -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()
+363
View File
@@ -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 (&#x0001; through &#x001F;) 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
# &#x0001; through &#x001F; and &#x007F; 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()
+8
View File
@@ -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" "$@"
+310
View File
@@ -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()
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.