Merge fix/physical-keys: physical keys and Input.key_label

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-15 23:26:53 +03:00
commit 9a43b14f80
14 changed files with 60552 additions and 60168 deletions

16
changes/physical-keys.md Normal file
View file

@ -0,0 +1,16 @@
bump: patch
type: fix
**Keys are physical positions on Windows and macOS, whatever the layout** - `Input.key_down('w')`
is the key above S on every keyboard.
- **Windows** keyed the held set by the character the active layout gave a virtual key, so
a game's WASD belonged to whatever the layout put there: on AZERTY W and A were other
keys, and with an input method on (Chinese, Japanese, Korean) every letter arrived as
VK_PROCESSKEY and no letter key worked at all. The typing block is now read from the
scancode; the arrows, the numpad and the F-keys still go by virtual key, and the frame
key (`Input.key`, typing) keeps the layout's character.
- **macOS** did the same through `charactersIgnoringModifiers`; it now reads `keyCode`.
- **`Input.key_label(key)`** names a key for the player in their own layout - `'w'` shows
as "W" on QWERTY and "Z" on AZERTY - so a game that shows its bindings never shows a
key the player cannot find. Windows reads the layout; headless, macOS and the browser
give the US character.

View file

@ -0,0 +1,26 @@
---
id: input-key_label
name: Input.key_label
category: input
kind: namespace-method
tokens: Input.key_label
sig: Input.key_label(key) -> string
tip: The name to show a player for a key - in their own keyboard layout.
order: 11
ns: Input
member: key_label
---
Returns the name to show for <code>key</code>. The letter, digit and punctuation codes that <a href="input-key_down.html"><code>Input.key_down</code></a> reads are <em>physical</em> keys, named for what they type on a US layout: <code>'w'</code> is the key above <code>'s'</code> on every keyboard, whatever layout, language, input method or Caps Lock state the player has. So WASD is always under the left hand, and the label is what that key types in the player's layout - <code>"W"</code> on QWERTY, <code>"Z"</code> on AZERTY. Space, Enter, Esc, Tab, Backspace, Shift, Ctrl, Alt and the arrows get words, and a code with no name returns <code>""</code>.
On Windows the label is read from the active layout. Headless, on macOS and in the browser it is the US character, upper case.
```ludic
program Demo {
entry {
print(Input.key_label('w'))
print(Input.key_label(32))
print(Input.key_label(Key.Left))
}
}
```

View file

@ -0,0 +1,24 @@
# input_key_label.ludic — the name a player sees for a key. Letters, digits and
# punctuation are physical positions named for the US layout; headless the label is that
# character upper case, and the named keys and arrows are words. An unnamed code is "".
#
# Deterministic; a full run prints: W A ; 1 Space Esc Shift Left Right 0 1
program InputKeyLabel {
function bi(b: bool) -> int { if b { return 1 }; return 0 }
entry {
print(Input.key_label('w'))
print(Input.key_label('a'))
print(Input.key_label(';'))
print(Input.key_label('1'))
print(Input.key_label(32))
print(Input.key_label(27))
print(Input.key_label(16))
print(Input.key_label(Key.Left))
print(Input.key_label(Key.Right))
print(len(Input.key_label(200)))
Input.press('a')
Input.poll()
print(bi(Input.key_down('a'))) # the held set is unchanged by naming
}
}

View file

@ -333,11 +333,35 @@ ret:
; keyDown switch: arrows -> WASD, return, escape -> 27, else the first character).
; #88: escape delivers key 27 (left to the game), not 'q' — so the held-key set
; stays consistent with @W_key and a shipped game owns Esc (pause).
; A key in the typing block is its PHYSICAL position, named for what it types on a US
; layout (win32.ll keys its held set the same way): keyCode is already a position, so the
; kVK_ANSI_* codes map straight through and AZERTY's Z-labelled key is 'w' like everywhere
; else. Anything outside the table falls back to the character it types.
@C_kc2key = internal constant [51 x i8] [i8 97, i8 115, i8 100, i8 102, i8 104, i8 103, i8 122, i8 120, i8 99, i8 118, i8 0, i8 98, i8 113, i8 119, i8 101, i8 114, i8 121, i8 116, i8 49, i8 50, i8 51, i8 52, i8 54, i8 53, i8 61, i8 57, i8 55, i8 45, i8 56, i8 48, i8 93, i8 111, i8 117, i8 91, i8 105, i8 112, i8 0, i8 108, i8 106, i8 39, i8 107, i8 59, i8 92, i8 44, i8 47, i8 110, i8 109, i8 46, i8 0, i8 0, i8 96]
; what the active layout types on a position: not read on macOS yet, so the caller names
; the key after its US position
define i32 @win_key_char(i32 %k) {
entry:
ret i32 0
}
define i32 @ev_keyval(ptr %ev) {
entry:
%sel_kc = call ptr @sel_registerName(ptr @.s_keycd)
%kc = call i16 (ptr, ptr) @objc_msgSend(ptr %ev, ptr %sel_kc)
%kc32 = zext i16 %kc to i32
%inpos = icmp ult i32 %kc32, 51
br i1 %inpos, label %bypos, label %special
bypos:
%pp = getelementptr [51 x i8], ptr @C_kc2key, i64 0, i32 %kc32
%p8 = load i8, ptr %pp
%pos = zext i8 %p8 to i32
%named = icmp ne i32 %pos, 0
br i1 %named, label %haspos, label %special
haspos:
ret i32 %pos
special:
switch i32 %kc32, label %chars [
i32 126, label %vw
i32 125, label %vs

View file

@ -372,6 +372,46 @@ function input_key_down(k: int) -> bool { in_init(); return in_bit_get(in_h
function input_key_pressed(k: int) -> bool { in_init(); return in_bit_get(in_held, k) and (not in_bit_get(in_prev, k)) }
function input_key_released(k: int) -> bool { in_init(); return (not in_bit_get(in_held, k)) and in_bit_get(in_prev, k) }
# The name to show a player for key code `k`. A letter, digit or punctuation code is a
# PHYSICAL key - the one that types it on a US layout - so the name is what the player's
# own layout types there: 'w' reads "W" on QWERTY and "Z" on AZERTY, and a binding
# shown as "W" is never a key the player cannot find. Named keys and the arrows get words.
# Headless, and on a platform that cannot ask the layout, it is the US character.
function input_key_label(k: int) -> string {
if k == 32 { return "Space" }
if k == 10 { return "Enter" }
if k == 27 { return "Esc" }
if k == 9 { return "Tab" }
if k == 8 { return "Backspace" }
if k == 16 { return "Shift" }
if k == 17 { return "Ctrl" }
if k == 18 { return "Alt" }
if k == 128 { return "Up" }
if k == 129 { return "Down" }
if k == 130 { return "Left" }
if k == 131 { return "Right" }
var c = 0
if is_windowed() { c = win_key_char(k) }
if c <= 0 and k > 32 and k < 127 {
c = k
if c >= 97 and c <= 122 { c = c - 32 }
}
if c <= 0 { return "" }
return in_utf8(c)
}
# one code point as a UTF-8 string
function in_utf8(c: int) -> string {
let out = bytes(5)
var n = 0
if c < 128 { out[0] = c; n = 1 }
else if c < 2048 { out[0] = 192 | (c >> 6); out[1] = 128 | (c & 63); n = 2 }
else if c < 65536 { out[0] = 224 | (c >> 12); out[1] = 128 | ((c >> 6) & 63); out[2] = 128 | (c & 63); n = 3 }
else { out[0] = 240 | (c >> 18); out[1] = 128 | ((c >> 12) & 63); out[2] = 128 | ((c >> 6) & 63); out[3] = 128 | (c & 63); n = 4 }
out[n] = 0
let s: string = out
return s
}
# inject a held key (AI, tutorial, testing, network) — persists until released.
function input_press(k: int) -> void { in_init(); in_bit_set(in_sim, k, true) }
function input_release(k: int) -> void { in_init(); in_bit_set(in_sim, k, false) }

View file

@ -17,7 +17,12 @@
; held keys a 256-bit set of Ludic key codes: letters lowercase, digits and
; punctuation as their unshifted character, Space 32, Enter 10,
; Esc 27, Backspace 8, Tab 9, Shift 16, Ctrl 17, Alt 18, and the
; arrows at 128 129 130 131 (up down left right)
; arrows at 128 129 130 131 (up down left right). A key in the
; typing block is its PHYSICAL position, named for what it types
; on a US layout: 'w' is the key right of Tab's row start on every
; layout, language, input method and Caps Lock state
; key char win_key_char(code): what the active layout types on that key,
; upper case, 0 when it types nothing or the code is not a position
; frame key the last key pressed this frame, arrows reported as w s a d
; mouse framebuffer pixels (client pixels / scale), top-left origin;
; buttons bit0 left, bit1 right; wheel in notches, up positive
@ -27,8 +32,13 @@
; backing scale is 1, and a 1280x720 window is 1280x720 pixels on any display.
;
; Keys are read from the message queue before dispatch rather than from WM_CHAR:
; TranslateMessage is never called, so no character messages are produced, and a
; key's character comes from MapVirtualKeyA. The system keys (Alt, F10) are never
; TranslateMessage is never called, so no character messages are produced. The held
; set is keyed by the scancode in lParam, not by the virtual key: a virtual key is
; the layout's idea of the key, so on AZERTY the key where W is printed on a US
; board arrives as VK_Z, and an input method turns every letter into VK_PROCESSKEY
; (229) - WASD simply stopped existing for those players. Keys outside the table
; (arrows, the numpad, F-keys, the ISO key) still go by virtual key, and the frame
; key keeps the layout's character, because that one is typing. The system keys (Alt, F10) are never
; handed to DefWindowProc, whose menu loop would stall the game's frame loop -
; Alt+F4 is honoured here instead.
; ============================================================================
@ -56,6 +66,8 @@ declare i32 @IsWindow(ptr)
declare ptr @SetCapture(ptr)
declare i32 @ReleaseCapture()
declare i32 @MapVirtualKeyA(i32, i32)
declare i32 @MapVirtualKeyW(i32, i32)
declare ptr @CharUpperW(ptr)
declare i16 @GetKeyState(i32)
declare i32 @RegisterRawInputDevices(ptr, i32, i32)
declare i32 @GetRawInputData(ptr, i32, ptr, ptr, i32)
@ -306,9 +318,32 @@ entry:
ret i32 %c7
}
; the held-set code of a virtual key (cocoa.ll's ev_keyval)
define internal i32 @w_keyval(i64 %vk64) {
; The typing block by position: a set-1 scancode (non-extended) to the key code it is on a
; US layout, and back. 0 is a key the table does not name.
@W_sc2key = internal constant [128 x i8] [i8 0, i8 0, i8 49, i8 50, i8 51, i8 52, i8 53, i8 54, i8 55, i8 56, i8 57, i8 48, i8 45, i8 61, i8 0, i8 0, i8 113, i8 119, i8 101, i8 114, i8 116, i8 121, i8 117, i8 105, i8 111, i8 112, i8 91, i8 93, i8 0, i8 0, i8 97, i8 115, i8 100, i8 102, i8 103, i8 104, i8 106, i8 107, i8 108, i8 59, i8 39, i8 96, i8 0, i8 92, i8 122, i8 120, i8 99, i8 118, i8 98, i8 110, i8 109, i8 44, i8 46, i8 47, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0]
@W_key2sc = internal constant [128 x i8] [i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 40, i8 0, i8 0, i8 0, i8 0, i8 51, i8 12, i8 52, i8 53, i8 11, i8 2, i8 3, i8 4, i8 5, i8 6, i8 7, i8 8, i8 9, i8 10, i8 0, i8 39, i8 0, i8 13, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 0, i8 26, i8 43, i8 27, i8 0, i8 0, i8 41, i8 30, i8 48, i8 46, i8 32, i8 18, i8 33, i8 34, i8 35, i8 23, i8 36, i8 37, i8 38, i8 50, i8 49, i8 24, i8 25, i8 16, i8 19, i8 31, i8 20, i8 22, i8 47, i8 17, i8 45, i8 21, i8 44, i8 0, i8 0, i8 0, i8 0, i8 0]
; the held-set code of a key message: the scancode's position when the table names it,
; the virtual key otherwise (cocoa.ll's ev_keyval)
define internal i32 @w_keyval(i64 %vk64, i64 %lp) {
entry:
%sc64 = lshr i64 %lp, 16
%sc = and i64 %sc64, 255
%ext64 = lshr i64 %lp, 24
%ext = and i64 %ext64, 1
%plain = icmp eq i64 %ext, 0
%inrange = icmp ult i64 %sc, 128
%lookup = and i1 %plain, %inrange
br i1 %lookup, label %bypos, label %byvk
bypos:
%sp = getelementptr [128 x i8], ptr @W_sc2key, i64 0, i64 %sc
%s8 = load i8, ptr %sp
%pos = zext i8 %s8 to i32
%named = icmp ne i32 %pos, 0
br i1 %named, label %haspos, label %byvk
haspos:
ret i32 %pos
byvk:
%vk = trunc i64 %vk64 to i32
switch i32 %vk, label %chars [
i32 38, label %up
@ -347,6 +382,39 @@ chars:
ret i32 %f
}
; What the active layout types on the key at held-set code %k, upper case: 'W' for 'w' on a
; US layout, 'Z' on AZERTY, 0x131 for 'i' on Turkish F... 0 when %k is not a position in the
; table or the key types nothing. A dead key reports its accent; the top bit that marks it is
; dropped.
define i32 @win_key_char(i32 %k) {
entry:
%inrange = icmp ult i32 %k, 128
br i1 %inrange, label %look, label %none
look:
%kp = getelementptr [128 x i8], ptr @W_key2sc, i64 0, i32 %k
%s8 = load i8, ptr %kp
%sc = zext i8 %s8 to i32
%has = icmp ne i32 %sc, 0
br i1 %has, label %map, label %none
map:
%vk = call i32 @MapVirtualKeyW(i32 %sc, i32 1) ; MAPVK_VSC_TO_VK
%c = call i32 @MapVirtualKeyW(i32 %vk, i32 2) ; MAPVK_VK_TO_CHAR
%c16 = and i32 %c, 65535
%types = icmp ne i32 %c16, 0
br i1 %types, label %up, label %none
up:
; CharUpperW takes a single character in the low word of its pointer argument
%cz = zext i32 %c16 to i64
%cp = inttoptr i64 %cz to ptr
%uq = call ptr @CharUpperW(ptr %cp)
%ui = ptrtoint ptr %uq to i64
%u16 = and i64 %ui, 65535
%u = trunc i64 %u16 to i32
ret i32 %u
none:
ret i32 0
}
; the frame key of a virtual key (cocoa.ll's win_poll): arrows as wasd, a letter in the
; case Shift gives it, and 0 for a key that types nothing
define internal i32 @w_framekey(i64 %vk64) {
@ -422,7 +490,7 @@ handle:
i32 255, label %raw ; WM_INPUT
]
kdown:
%hv = call i32 @w_keyval(i64 %wp)
%hv = call i32 @w_keyval(i64 %wp, i64 %lp)
call void @win_held_bit(i32 %hv, i32 1)
%isf4 = icmp eq i64 %wp, 115 ; VK_F4
%issys = icmp eq i32 %m, 260
@ -439,7 +507,7 @@ setfk:
store i32 %fk, ptr @W_key
br label %pump
kup:
%uv = call i32 @w_keyval(i64 %wp)
%uv = call i32 @w_keyval(i64 %wp, i64 %lp)
call void @win_held_bit(i32 %uv, i32 0)
br label %pump
ldown:

View file

@ -303,6 +303,9 @@ async function start({ wasm, canvas, manifest = null, files = {}, onExit = null,
win_mouse: (p) => { host.sync(); const b = p >>> 2; for (let i = 0; i < 4; i++) host.u32[b + i] = 0; },
win_pad: (p) => { host.sync(); const b = p >>> 2; for (let i = 0; i < 24; i++) host.u32[b + i] = 0; },
win_touch: (p) => { host.sync(); const b = p >>> 2; for (let i = 0; i < 24; i++) host.u32[b + i] = 0; },
// what the layout types on a key position: not read in the browser, so Input.key_label
// names the key after its US position
win_key_char: (k) => 0,
};
const { instance } = await WebAssembly.instantiateStreaming(fetch(wasm), { env });

View file

@ -449,6 +449,7 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "key_down") { bare = "input_key_down"; push(labels, "key") }
if (meth == "key_pressed") { bare = "input_key_pressed"; push(labels, "key") }
if (meth == "key_released") { bare = "input_key_released"; push(labels, "key") }
if (meth == "key_label") { bare = "input_key_label"; push(labels, "key") }
if (meth == "press") { bare = "input_press"; push(labels, "key") }
if (meth == "release") { bare = "input_release"; push(labels, "key") }
if (meth == "axis") { bare = "input_axis"; push(labels, "neg"); push(labels, "pos") }

View file

@ -108,6 +108,7 @@ function emit_header() -> void {
emith("declare i32 @win_running()\n")
emith("declare void @win_close()\n")
emith("declare void @win_held(ptr)\n")
emith("declare i32 @win_key_char(i32)\n")
emith("declare void @win_mouse(ptr)\n")
emith("declare void @win_pad(ptr)\n")
emith("declare void @win_touch(ptr)\n")

View file

@ -21,6 +21,7 @@ function is_intrinsic2(name: pointer) -> bool {
if (name == "win_open") or (name == "win_poll") or (name == "win_present") { return true }
if (name == "win_running") or (name == "win_close") { return true }
if (name == "win_held") or (name == "win_mouse") { return true } # #50 device layer
if (name == "win_key_char") { return true } # Input.key_label
if (name == "win_pad") or (name == "win_touch") { return true } # #51 gamepad / touch
if (name == "win_cursor_mode") { return true } # #89 cursor capture
if (name == "snd_load") or (name == "snd_play") or (name == "snd_stop") { return true } # #22 audio
@ -71,6 +72,9 @@ function emit_intrinsic2(name: pointer, e: Node) -> Val {
# button-mask, wheel-delta].
if (name == "win_held") { let a = arg_code(e, 0); emit(" call void @win_held(ptr "); emit(a); emit(")\n"); return val("0", "void") }
if (name == "win_mouse") { let a = arg_code(e, 0); emit(" call void @win_mouse(ptr "); emit(a); emit(")\n"); return val("0", "void") }
# what the active keyboard layout types on a held-set key position (windowed only,
# DCE'd headless): a code point, 0 for none.
if (name == "win_key_char") { let a = arg_code(e, 0); return val(emit_bind(`call i32 @win_key_char(i32 {a})`), "int") }
# #51 — win_pad: 6 words/pad [connected, mask, lx, ly, rx, ry]; win_touch: 3
# words/point [active, x, y]. Both windowed-only, DCE'd headless.
if (name == "win_pad") { let a = arg_code(e, 0); emit(" call void @win_pad(ptr "); emit(a); emit(")\n"); return val("0", "void") }

View file

@ -241,7 +241,7 @@ function p_postfix() -> Node {
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "bind" or e.s == "rebind" or e.s == "poll" or e.s == "down" or e.s == "pressed" or e.s == "record" or e.s == "replay" or e.s == "action" or e.s == "bind_pad" or e.s == "active" or e.s == "just_pressed" or e.s == "just_released") { g_uses_input = true }
# #50 device layer — any of the multi-key / analog / mouse / gamepad / touch
# methods also splices input.ludic (Input.key stays bare, no runtime).
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "key_down" or e.s == "key_pressed" or e.s == "key_released" or e.s == "press" or e.s == "release" or e.s == "axis" or e.s == "axis_i" or e.s == "vector" or e.s == "strength" or e.s == "mouse_x" or e.s == "mouse_y" or e.s == "mouse_dx" or e.s == "mouse_dy" or e.s == "mouse_down" or e.s == "wheel" or e.s == "set_mouse" or e.s == "pad_connected" or e.s == "pad_button" or e.s == "pad_axis" or e.s == "move_i" or e.s == "set_pad" or e.s == "touch_count" or e.s == "touch_x" or e.s == "touch_y" or e.s == "set_touch" or e.s == "cursor_mode") { g_uses_input = true }
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "key_down" or e.s == "key_pressed" or e.s == "key_released" or e.s == "key_label" or e.s == "press" or e.s == "release" or e.s == "axis" or e.s == "axis_i" or e.s == "vector" or e.s == "strength" or e.s == "mouse_x" or e.s == "mouse_y" or e.s == "mouse_dx" or e.s == "mouse_dy" or e.s == "mouse_down" or e.s == "wheel" or e.s == "set_mouse" or e.s == "pad_connected" or e.s == "pad_button" or e.s == "pad_axis" or e.s == "move_i" or e.s == "set_pad" or e.s == "touch_count" or e.s == "touch_x" or e.s == "touch_y" or e.s == "set_touch" or e.s == "cursor_mode") { g_uses_input = true }
# Anim.play/clip/on_frame/fired + Motion.to (#48): the ergonomic writes over
# the SpriteAnim/Motion components live in systems.ludic and use the world
# table, so splice it and force the reflection ABI even if the game leaves

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -682,6 +682,7 @@ function cmd_dev_test() -> int {
feat_case("library/input_edge", "xkkxq", "0 11 1 100 0", "input_edge.ludic (#87 Input.key_pressed/key_released edges fire on the transition frame; no double-commit under the frame loop)")
feat_case("library/input_movement", "", "1 0 -1 -1 0", "input_movement.ludic (#79 Input.axis_i turns WASD into a -1/0/1 movement intent, no bool->int glue)")
feat_case("library/input_arrows", "", "128 129 130 131 1 0 -1 1 0", "input_arrows.ludic (Key.Up/Down/Left/Right are the held set's 128-131: key_down and move_i take the arrows)")
feat_case("library/input_key_label", "", "W A ; 1 Space Esc Shift Left Right 0 1", "input_key_label.ludic (Input.key_label names a physical key: the US character headless, words for the named keys and arrows)")
feat_case("library/audio", "", "0 0 0 0 1", "audio.ludic (#22 Audio.* load/play/music/volume/pitch/stop/is_playing — headless no-op)")
# #6 Http.* — the client links Foundation (macOS-only), so build it through the
# canonical `ludicc -o` path (which wires the framework) and gate on Darwin. The