Pantrix UI

Klavye kısayolları

Compose Multiplatform'da bütün pencereye ait bir klavye kısayolunun (Cmd+K ya da Ctrl+K gibi) neden tek bir onPreviewKeyEvent ile çalışmadığı, kütüphanenin bunun için ne verdiği, bir kısayolun her sistemde nasıl yazıldığı ve bunun nasıl sınandığı. Sorun Pantrix'e özgü değildir: ortak kodunda bir uygulama kısayolu olan her Compose Multiplatform projesinde aynı şekilde görülür.

Compose hakkında burada söylenen her şey Compose Multiplatform 1.12.1'in kaynaklarında okunmuş ya da bu depoda koşan bir testte, bir cihazda veya tarayıcıda görülmüştür; hangisinin nerede görüldüğü 7. ve 8. bölümlerde yazılıdır.

1. Sorun

Belirti

Neden

Bir tuş olayı, odaktaki düğüme ve onun atalarına verilir. Hiçbir şey odakta değilken Compose olayı, kök odak düğümünün üstündeki dinleyicilere verir: içerikteki hiçbir dinleyici onlardan biri değildir. FocusOwnerImpl.dispatchKeyEvent (Compose Multiplatform 1.12.1, commonMain/androidx/compose/ui/focus/FocusOwnerImpl.kt):

val activeFocusTarget = findFocusTargetNode()
val focusedKeyInputNode =
    activeFocusTarget?.lastLocalKeyInputNode()
        ?: activeFocusTarget?.nearestAncestorIncludingSelf(Nodes.KeyInput)?.node
        ?: rootFocusNode.nearestAncestor(Nodes.KeyInput)?.node

rootFocusNode.nearestAncestor(…) platformun kendi düğümleridir; uygulamanın kökündeki onPreviewKeyEvent onların altındadır ve atlanır. Bir kısayolun her zaman duyulması için, hiçbir şey odakta değilken kökün kendisinin odakta olması gerekir.

Bunu yapan bir düğüm de iki şeye dikkat etmelidir:

2. Çözüm: üç parça

ParçaNe yapar
PantrixShortcut(key, command, shift, alt)Bir tuş ve değiştiriciler. command Apple'da Command, ötekilerde Control'dür. isPressedBy(event, system): tuşu inerken tam bu değiştiriciler basılı mı, başkası değil. keysOn(system), writtenOn(system): sistemin yazdığı gibi.
Modifier.onShortcut(shortcut) { … }Odak bu düğümdeyken ya da içindeyken kısayolu, odaktaki kontrolden önce duyar (onPreKeyEvent). Çalıştırdığı tuşun kalkışını da yutar.
Modifier.shortcutRoot()İçinde hiçbir şey odakta değilken odağı tutar; Tab için durak değildir.

LocalOperatingSystem (foundation/platform), kısayolun hangi sistemde basıldığını söyler: varsayılanı currentOperatingSystem()'dır; bir test başka bir sistem verir.

3. Pantrix ile

3.1. Bütün pencerenin kısayolu

var palette by rememberSaveable { mutableStateOf(false) }
Box(Modifier.shortcutRoot().onShortcut(PantrixShortcut(Key.K, command = true)) { palette = true }) {
    App()
    PantrixCommandPalette(visible = palette, onDismissRequest = { palette = false }, …)
}

shortcutRoot pencerenin kökünde bir kez durur. Kısayollar onunla aynı düğümde ya da onun üstünde olmalıdır: odak kökteyken yalnızca kök ve atalarının dinleyicileri duyar. İki kök bir an yan yana durduğunda, biri ötekine yerini bırakırken iki ekranın durduğu gibi, odağı tutan onu bırakmaz.

3.2. Komutların kendi kısayolları

Modifier.commandShortcuts(commands, onRun) listedeki her çalıştırılabilir komutun kısayolunu bağlar. Paletin gösterdiği ile klavyenin çalıştırdığı aynı listedir:

Box(
    Modifier
        .shortcutRoot()
        .onShortcut(PantrixShortcut(Key.K, command = true)) { palette = true }
        .commandShortcuts(commands, onRun = { run(it.key) })
) { … }

enabled = false olan bir komut tuşunu geçirir; aynı kısayolu taşıyan iki komuttan ilki çalışır. Bir Pantrix modal'ı açıkken tuşlar onundur: altındaki kısayollar duyulmaz.

3.3. Kısayolun yazılışı

val shortcut = PantrixShortcut(Key.R, command = true, shift = true)
shortcut.writtenOn(OperatingSystem.MacOs)   // "⇧⌘R"
shortcut.writtenOn(OperatingSystem.Windows) // "Ctrl+Shift+R"
shortcut.keysOn(LocalOperatingSystem.current) // tuş kapakları için, birer birer

Apple'ın sırası Option, Shift, Command ve tuştur; ötekilerinki Ctrl, Alt, Shift ve tuş. Harflerin, rakamların, okların, F1–F12'nin, Enter'ın, Escape'in, Tab'ın, boşluğun, Backspace'in, Delete'in, Home ile End'in, sayfa tuşlarının ve yaygın işaretlerin adı vardır; başka bir tuş #numarası diye yazılır.

4. Pantrix olmadan

Kökün düğümü, ShortcutRootNode (ui/foundation/.../keyboard/Shortcuts.kt), kütüphanesiz şöyle kurulur:

fun Modifier.shortcutRoot(): Modifier = this then ShortcutRootElement

private object ShortcutRootElement : ModifierNodeElement<ShortcutRootNode>() {
    override fun create() = ShortcutRootNode()
    override fun update(node: ShortcutRootNode) = Unit
    override fun hashCode() = "shortcutRoot".hashCode()
    override fun equals(other: Any?) = other === this
}

private class ShortcutRootNode : DelegatingNode(), KeyInputModifierNode, CompositionLocalConsumerModifierNode {
    private val target = delegate(
        FocusTargetModifierNode(focusability = Focusability.Always, onFocusChange = { _, state -> changed(state) })
    )

    override fun onAttach() = takeIfNothingHasIt()

    override fun onPreKeyEvent(event: KeyEvent) = false

    // Kök odaktayken Tab'ı kendisi alır ve içindekilere götürür.
    override fun onKeyEvent(event: KeyEvent): Boolean {
        if (event.type != KeyEventType.KeyDown || event.key != Key.Tab || !target.focusState.isFocused) return false
        val focus = currentValueOf(LocalFocusManager)
        focus.moveFocus(FocusDirection.Enter)
        if (event.isShiftPressed) focus.moveFocus(FocusDirection.Previous)
        return true
    }

    private fun changed(state: FocusState) {
        // İçeride bir şey odaktayken kök Tab'ın durağı değildir; başa dönüş odağı bırakıp ilkini aradığı için de
        // kök, odaklanabilirliğini ancak her şey yatıştıktan sonra geri alır.
        if (state.hasFocus && !state.isFocused) target.focusability = Focusability.Never
        if (!state.hasFocus) takeIfNothingHasIt()
    }

    private fun takeIfNothingHasIt() {
        coroutineScope.launch {
            if (!target.focusState.hasFocus) {
                target.focusability = Focusability.Always
                target.requestFocus()
            }
        }
    }
}

Eşleşme kuralı tek satırdır; sistem için navigator.platform (tarayıcı) ya da os.name (JVM) okunur:

val lead = if (apple) event.isMetaPressed else event.isCtrlPressed
val stray = if (apple) event.isCtrlPressed else event.isMetaPressed
val pressed = event.type == KeyEventType.KeyDown && event.key == key && lead == command && !stray &&
    event.isShiftPressed == shift && event.isAltPressed == alt

5. Yardımcının yapmadıkları

6. Test etmek

setContent {
    CompositionLocalProvider(LocalOperatingSystem provides OperatingSystem.MacOs) {
        Box(Modifier.testTag("field").onShortcut(PantrixShortcut(Key.K, command = true)) { runs++ }.focusable())
    }
}
onNodeWithTag("field").requestFocus()
onNodeWithTag("field").performKeyInput { withKeyDown(Key.MetaLeft) { pressKey(Key.K) } }

7. Tuzaklar

8. Doğrulama

Testler

TestNe tutarHedefler
foundation/.../keyboard/ShortcutsTestYazılış (Apple ve ötekiler, her adlı tuş), eşitlik, tam değiştiricilerle basılma, odaktakine verilmeyen iniş ve kalkış, dıştakinin kazanması, kökün başta ve odak bırakılınca odağı alması, Tab'ın kökü atlamasıJVM, iOS simülatörü, JS, Wasm
components/.../CommandShortcutsTestHer komutun kısayolu, çalıştırılamayanın tuşu geçirmesi, ilkinin kazanmasıJVM, iOS, JS, Wasm
components/.../CommandPaletteTestPaletin satırlarında kısayolun sistemin yazdığı gibi durum olarak okunmasıJVM, iOS, JS, Wasm

Android'in ana makine testleri commonTest'i görmez (bkz. ROBORAZZI_GUIDE.md).

Elle bakılanlar

Doğrulanmayanlar

9. Bu depoda nerede

Dosyaİçinde
ui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/keyboard/PantrixShortcut.ktPantrixShortcut, tuş adları
ui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/keyboard/Shortcuts.ktonShortcut, shortcutRoot
ui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/platform/OperatingSystem.ktOperatingSystem, LocalOperatingSystem
ui/components/src/commonMain/kotlin/com/pantrixui/components/overlay/commandpalette/CommandShortcuts.ktcommandShortcuts
app/shared/src/commonMain/kotlin/com/pantrixui/cmp/demo/shell/DemoCommands.ktDemo'nun kökü ve kendi paleti

10. Açık kalanlar

docs/OPEN_ITEMS.md'de: sıralı kısayollar, basılı tutmanın yinelemesi, iPad'in kısayol listesi ve iOS'ta donanım klavyesiyle doğrulama.