Keep SwiftUI's named keys out of the ASCII recovery (#1106)

* Keep SwiftUI's named keys out of the ASCII recovery

* Cover the shortcut caller path, not just the recovery

* Skip the caller-path cases when the layout has no trap to spring

* Keep the recovery's contract in Platform, without the test seam
This commit is contained in:
Federico Zivolo
2026-09-24 23:41:32 +06:00
committed by GitHub
parent 7ca68b02aa
commit f2a27d7d5a
4 changed files with 101 additions and 6 deletions
+1
View File
@@ -251,6 +251,7 @@ run palette-filter-test Tinycast/Palette/PaletteMode.swift \
run action-menu-search-test Tinycast/Palette/ActionMenuSearchQuery.swift \
Tinycast/Features/Launcher/Model/SearchRelevance.swift
run palette-shortcut-test Tinycast/Palette/PaletteShortcut.swift
run ascii-layout-test Tinycast/Platform/ASCIIKeyboardLayout.swift
run palette-tab-test Tinycast/Palette/PaletteMode.swift \
Tinycast/Palette/PaletteTabAction.swift \
Tinycast/Features/Quicklinks/Model/Quicklink.swift \
+80
View File
@@ -0,0 +1,80 @@
import AppKit
import SwiftUI
/// `UCKeyTranslate` answers a named key's keycode with a control character the ASCII test admits.
@main
@MainActor
struct ASCIILayoutTests {
static var failures = 0
static var passes = 0
static func check(_ label: String, _ ok: Bool) {
if ok {
passes += 1
} else {
failures += 1
print("FAIL \(label)")
}
}
/// What `UCKeyTranslate` hands back for each keycode under ⌘, against SwiftUI's own spelling.
static let namedKeys: [(name: String, key: KeyEquivalent, translated: Character)] = [
("↑", .upArrow, "\u{1e}"),
("↓", .downArrow, "\u{1f}"),
("←", .leftArrow, "\u{1c}"),
("→", .rightArrow, "\u{1d}"),
("⌦", .deleteForward, "\u{7f}"),
("⇞", .pageUp, "\u{b}"),
("⇟", .pageDown, "\u{c}"),
("↖", .home, "\u{1}"),
("↘", .end, "\u{4}")
]
/// These four agree with SwiftUI already, and must keep resolving once the arrows are excluded.
static let controlKeys: [(name: String, key: KeyEquivalent, translated: Character)] = [
("↩", .return, "\u{d}"),
("⌫", .delete, "\u{8}"),
("⇥", .tab, "\u{9}"),
("⎋", .escape, "\u{1b}"),
("space", .space, " ")
]
static func main() {
print("# a named key is never recovered from the layout")
for entry in namedKeys {
check(
"\(entry.name) keeps SwiftUI's key",
ASCIIKeyboardLayout.recovered(entry.key, layoutCharacter: entry.translated)
== entry.key)
}
print("\n# a key whose control character is already SwiftUI's still resolves")
for entry in controlKeys {
check(
"\(entry.name) resolves to itself",
ASCIIKeyboardLayout.recovered(entry.key, layoutCharacter: entry.translated)
== entry.key)
}
print("\n# the recovery still does its job")
check(
"a non-QWERTY layout recovers the logical key",
ASCIIKeyboardLayout.recovered(KeyEquivalent("t"), layoutCharacter: "k")
== KeyEquivalent("k"))
check(
"a non-ASCII layout character falls back to SwiftUI's key",
ASCIIKeyboardLayout.recovered(KeyEquivalent("k"), layoutCharacter: "ц")
== KeyEquivalent("k"))
check(
"no layout character falls back to SwiftUI's key",
ASCIIKeyboardLayout.recovered(KeyEquivalent("k"), layoutCharacter: nil)
== KeyEquivalent("k"))
check(
"a shifted letter is still spelled lower case",
ASCIIKeyboardLayout.recovered(KeyEquivalent("C"), layoutCharacter: nil)
== KeyEquivalent("c"))
print("\n\(passes) passed, \(failures) failed")
exit(failures == 0 ? 0 : 1)
}
}
+15 -4
View File
@@ -45,11 +45,22 @@ enum ASCIIKeyboardLayout {
/// SwiftUI exposes the input-source character; recover the logical ASCII key from AppKit.
@MainActor static func keyEquivalent(fallingBackTo key: KeyEquivalent) -> KeyEquivalent {
guard let event = NSApp.currentEvent,
!event.modifierFlags.isDisjoint(with: [.command, .control]),
let character = character(for: event)?.lowercased().first,
character.unicodeScalars.allSatisfy(\.isASCII)
!event.modifierFlags.isDisjoint(with: [.command, .control])
else { return lowercased(key) }
return KeyEquivalent(character)
return recovered(key, layoutCharacter: character(for: event)?.lowercased().first)
}
/// The recovery itself, over a layout character the caller already translated.
static func recovered(_ key: KeyEquivalent, layoutCharacter: Character?) -> KeyEquivalent {
guard !isNamedKey(key), let layoutCharacter,
layoutCharacter.unicodeScalars.allSatisfy(\.isASCII)
else { return lowercased(key) }
return KeyEquivalent(layoutCharacter)
}
/// Arrows and page keys: `UCKeyTranslate` answers these with ASCII controls the test admits.
private static func isNamedKey(_ key: KeyEquivalent) -> Bool {
key.character.unicodeScalars.contains { (0xF700...0xF8FF).contains($0.value) }
}
/// Shift uppercases SwiftUI's key, but every chord spells its letter in lower case.
+5 -2
View File
@@ -520,8 +520,11 @@ Panel-owned chords use the same translation directly. A ⌘ chord translates thr
Command table, so "Dvorak – QWERTY ⌘" keeps giving QWERTY positions while Command is held; a ⌃ chord
translates without it, since only Command is remapped. A non-ASCII input source or IME therefore
cannot turn ⌘K into a different logical key, while Dvorak and other ASCII layouts keep their own
letter positions. No replacement event is synthesized, and unmodified typing stays on the active
input source and follows the normal composition path.
letter positions. A key SwiftUI spells in the private-use area — the arrows, and the page, home and
forward-delete keys — skips the recovery outright: `UCKeyTranslate` answers those keycodes with ASCII
control characters, which the ASCII test would otherwise accept in place of the key itself, and a
layout has no letter position to recover for them anyway. No replacement event is synthesized, and
unmodified typing stays on the active input source and follows the normal composition path.
## The keyboard belongs to the search field