From 1c856dc32b6b7d8def11eaee8ef48cacb4249ca9 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Fri, 7 Aug 2026 00:45:59 -0700 Subject: [PATCH] Fix the F3 sync and Ctrl+Alt+S shortcuts, and give them to Android and iOS (#865) * Handle F3 and Ctrl+Alt+S on the project window Both shortcuts hung off a Modifier.onPreviewKeyEvent in ProjectRootUi, so they only fired when focus sat inside the project subtree and did nothing from the home screen. Move them to the window onKeyEvent that already owns Esc, Ctrl+W, Ctrl+Q and Ctrl+Shift+F. F3 now goes through ProjectRoot.startProjectSync(), which opens the sync modal only for server-linked projects, restoring the gate the old sync menu item had. * Match shortcut modifiers exactly and run them pre-focus F3 and Ctrl+Alt+S were hand-rolled in the window's when-chain, which dropped the exact-modifier matching onKeyShortcut enforced: Ctrl+F3 and Ctrl+Alt+Shift+S both fired. Extract that predicate as KeyEvent.matchesShortcut and use it for both. Move the two to onPreviewKeyEvent as well. onKeyEvent only runs when nothing on the focus path consumed the key, so a focused editor could swallow them; the docs claimed otherwise. The other window shortcuts stay on onKeyEvent so a focused component can still handle Esc first. * Give Android and iOS the project shortcuts back Moving F3 and Ctrl+Alt+S to the desktop window left the other platforms with nothing. Add ProjectShortcutHost, which ProjectRootScaffold binds while the project UI is composed, and drive it from each platform's own key hook: Activity.dispatchKeyEvent on Android and UIKit key commands on iOS. Both are focus independent, which the Compose modifiers were not. The iOS container lives in Swift because keyCommands is an Objective-C category member and Kotlin cannot override those. * Bind the project shortcuts in one place Desktop kept its own copy of the save-all action while Android and iOS went through ProjectShortcutHost. Move the binding down to ProjectRootUi, the one composable all three platforms render, so each host only detects keys and calls the host object. That also lets the window drop the snackbar state and coroutine scope it had hoisted purely to run the action. --- .../hammer/android/ProjectRootActivity.kt | 28 ++++-- .../components/projectroot/ProjectRoot.kt | 3 + .../projectroot/ProjectRootComponent.kt | 6 ++ .../projectroot/ProjectRootComponentTest.kt | 28 ++++++ .../common/compose/ProjectShortcutHost.kt | 38 ++++++++ .../common/compose/ShortcutModifiers.kt | 29 +++--- .../common/projectroot/ProjectRootScaffold.kt | 17 +++- .../common/projectroot/ProjectRootUi.kt | 26 ++++-- .../common/compose/MatchesShortcutTest.kt | 91 +++++++++++++++++++ .../common/compose/ProjectShortcutHostTest.kt | 41 +++++++++ .../apps/hammer/common/MainViewController.kt | 13 ++- .../hammer/desktop/ProjectEditorWindow.kt | 21 ++++- docs/KEYBOARD-SHORTCUTS.md | 30 +++++- ios/ios/ComposeContainer.swift | 57 +++++++++++- 14 files changed, 380 insertions(+), 48 deletions(-) create mode 100644 composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ProjectShortcutHost.kt create mode 100644 composeUi/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/compose/MatchesShortcutTest.kt create mode 100644 composeUi/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/compose/ProjectShortcutHostTest.kt diff --git a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectRootActivity.kt b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectRootActivity.kt index bb10e9bd6..f82bf3075 100644 --- a/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectRootActivity.kt +++ b/android/src/main/kotlin/com/darkrockstudios/apps/hammer/android/ProjectRootActivity.kt @@ -30,6 +30,7 @@ import com.darkrockstudios.apps.hammer.android.shortcuts.ProjectShortcutsManager import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectDeepLink import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectRoot import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectRootComponent +import com.darkrockstudios.apps.hammer.common.compose.ProjectShortcutHost import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme import com.darkrockstudios.apps.hammer.common.data.ProjectDef import com.darkrockstudios.apps.hammer.common.data.closeProjectScope @@ -70,6 +71,7 @@ class ProjectRootActivity : AppCompatActivity() { private val viewModel: ProjectRootViewModel by viewModels() private var projectRoot: ProjectRoot? = null + private val shortcutHost = ProjectShortcutHost() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -185,14 +187,22 @@ class ProjectRootActivity : AppCompatActivity() { } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (event.action == KeyEvent.ACTION_DOWN && - event.keyCode == KeyEvent.KEYCODE_F && - event.isShiftPressed && - event.isCtrlPressed - ) { - projectRoot?.let { - it.showGlobalSearch() - return true + if (event.action == KeyEvent.ACTION_DOWN) { + if (event.keyCode == KeyEvent.KEYCODE_F && event.isShiftPressed && event.isCtrlPressed) { + projectRoot?.let { + it.showGlobalSearch() + return true + } + } + + // hasModifiers matches exactly, so Ctrl+F3 and Ctrl+Alt+Shift+S fall through. + if (event.keyCode == KeyEvent.KEYCODE_F3 && event.hasNoModifiers()) { + if (shortcutHost.startProjectSync()) return true + } + + val ctrlAlt = KeyEvent.META_CTRL_ON or KeyEvent.META_ALT_ON + if (event.keyCode == KeyEvent.KEYCODE_S && event.hasModifiers(ctrlAlt)) { + if (shortcutHost.saveAllBuffers()) return true } } return super.dispatchKeyEvent(event) @@ -214,7 +224,7 @@ class ProjectRootActivity : AppCompatActivity() { component.requestClose() } - ProjectRootScaffold(component, onCloseRequest = ::finish) + ProjectRootScaffold(component, onCloseRequest = ::finish, shortcutHost = shortcutHost) } companion object { diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRoot.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRoot.kt index 8bdf11397..f897bd19f 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRoot.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRoot.kt @@ -45,6 +45,9 @@ interface ProjectRoot : AppCloseManager, HammerComponent, BackHandlerOwner { fun isAtRoot(): Boolean fun showProjectSync() + + /** Opens the sync modal only when this project is linked to a server. */ + fun startProjectSync() fun dismissProjectSync() fun showGlobalSearch() diff --git a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootComponent.kt b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootComponent.kt index 1d4ec3a72..502ea0598 100644 --- a/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootComponent.kt +++ b/common/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/components/projectroot/ProjectRootComponent.kt @@ -234,6 +234,12 @@ class ProjectRootComponent( override fun showProjectSync() = modalRouter.showProjectSync() + override fun startProjectSync() { + if (syncJournal.isServerSynchronized()) { + showProjectSync() + } + } + override fun dismissProjectSync() = modalRouter.dismissProjectSync() override fun showGlobalSearch() = modalRouter.showGlobalSearch() diff --git a/common/src/desktopTest/kotlin/components/projectroot/ProjectRootComponentTest.kt b/common/src/desktopTest/kotlin/components/projectroot/ProjectRootComponentTest.kt index a12add5af..ded4670e8 100644 --- a/common/src/desktopTest/kotlin/components/projectroot/ProjectRootComponentTest.kt +++ b/common/src/desktopTest/kotlin/components/projectroot/ProjectRootComponentTest.kt @@ -281,6 +281,34 @@ class ProjectRootComponentTest : ComponentTest() { assertTrue(comp.isAtRoot()) } + @Test + fun `startProjectSync opens the modal for server synchronized projects`() = runTest(mainTestDispatcher) { + every { syncJournal.isServerSynchronized() } returns true + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.startProjectSync() + advanceUntilIdle() + + assertIs(comp.modalRouterState.value.child?.instance) + } + + @Test + fun `startProjectSync does nothing for local-only projects`() = runTest(mainTestDispatcher) { + every { syncJournal.isServerSynchronized() } returns false + + val comp = newComponent() + context.resume() + advanceUntilIdle() + + comp.startProjectSync() + advanceUntilIdle() + + assertIs(comp.modalRouterState.value.child?.instance) + } + @Test fun `Global search modal opens and dismisses`() = runTest(mainTestDispatcher) { val comp = newComponent() diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ProjectShortcutHost.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ProjectShortcutHost.kt new file mode 100644 index 000000000..704cc49a7 --- /dev/null +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ProjectShortcutHost.kt @@ -0,0 +1,38 @@ +package com.darkrockstudios.apps.hammer.common.compose + +import androidx.compose.runtime.Stable + +/** + * Lets a platform host (Activity, view controller, window) fire the project shortcuts from its + * own key handling. Compose's key modifiers only see events routed along the focus path, so a + * screen with nothing focused never receives them. + * + * Each action returns false when no project UI is bound, so the host can pass the key on. + */ +@Stable +class ProjectShortcutHost { + private var startSync: (() -> Unit)? = null + private var saveAll: (() -> Unit)? = null + + fun bind(startSync: () -> Unit, saveAll: () -> Unit) { + this.startSync = startSync + this.saveAll = saveAll + } + + fun unbind() { + startSync = null + saveAll = null + } + + fun startProjectSync(): Boolean { + val action = startSync ?: return false + action() + return true + } + + fun saveAllBuffers(): Boolean { + val action = saveAll ?: return false + action() + return true + } +} diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ShortcutModifiers.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ShortcutModifiers.kt index e580425e9..36b3425c9 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ShortcutModifiers.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ShortcutModifiers.kt @@ -3,6 +3,21 @@ package com.darkrockstudios.apps.hammer.common.compose import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.* +/** Matches a key-down with exactly these modifiers: extra ones do not count as a match. */ +fun KeyEvent.matchesShortcut( + key: Key, + ctrl: Boolean = false, + shift: Boolean = false, + alt: Boolean = false, +): Boolean { + val ctrlOrMeta = isCtrlPressed || isMetaPressed + return type == KeyEventType.KeyDown && + this.key == key && + ctrlOrMeta == ctrl && + isShiftPressed == shift && + isAltPressed == alt +} + internal fun Modifier.onKeyShortcut( key: Key, ctrl: Boolean = false, @@ -10,13 +25,7 @@ internal fun Modifier.onKeyShortcut( alt: Boolean = false, action: () -> Unit, ): Modifier = onPreviewKeyEvent { event -> - val ctrlOrMeta = event.isCtrlPressed || event.isMetaPressed - if (event.type == KeyEventType.KeyDown && - event.key == key && - ctrlOrMeta == ctrl && - event.isShiftPressed == shift && - event.isAltPressed == alt - ) { + if (event.matchesShortcut(key, ctrl, shift, alt)) { action() true } else { @@ -30,12 +39,6 @@ fun Modifier.findShortcutModifier(showFindBar: () -> Unit): Modifier = fun Modifier.saveShortcutModifier(onSave: () -> Unit): Modifier = onKeyShortcut(Key.S, ctrl = true, action = onSave) -fun Modifier.saveAllShortcutModifier(onSaveAll: () -> Unit): Modifier = - onKeyShortcut(Key.S, ctrl = true, alt = true, action = onSaveAll) - -fun Modifier.syncShortcutModifier(onSync: () -> Unit): Modifier = - onKeyShortcut(Key.F3, action = onSync) - fun Modifier.boldShortcutModifier(onBold: () -> Unit): Modifier = onKeyShortcut(Key.B, ctrl = true, action = onBold) diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootScaffold.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootScaffold.kt index c2b828c01..0eacf0e84 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootScaffold.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootScaffold.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.common.components.projectroot.CloseConfirm import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectRoot +import com.darkrockstudios.apps.hammer.common.compose.ProjectShortcutHost import com.darkrockstudios.apps.hammer.common.compose.RootSnackbarHostState import com.darkrockstudios.apps.hammer.common.compose.defaultScaffold import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdBottomBar @@ -46,6 +47,7 @@ const val NAV_TIMELINE_TAG = "nav-TimeLine" fun ProjectRootScaffold( component: ProjectRoot, onCloseRequest: () -> Unit, + shortcutHost: ProjectShortcutHost? = null, ) { val shouldConfirmClose by component.closeRequestHandlers.subscribeAsState() val themeState by component.projectTheme.subscribeAsState() @@ -55,9 +57,9 @@ fun ProjectRootScaffold( ProjectThemeOverride(themeState.theme) { val windowSizeClass = calculateWindowSizeClass() when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Compact -> CompactNavigation(component, rootSnackbar) + WindowWidthSizeClass.Compact -> CompactNavigation(component, rootSnackbar, shortcutHost) WindowWidthSizeClass.Medium, - WindowWidthSizeClass.Expanded -> RailNavigation(component, rootSnackbar) + WindowWidthSizeClass.Expanded -> RailNavigation(component, rootSnackbar, shortcutHost) } if (shouldConfirmClose.isNotEmpty()) { @@ -77,6 +79,7 @@ fun ProjectRootScaffold( private fun CompactNavigation( component: ProjectRoot, rootSnackbar: RootSnackbarHostState, + shortcutHost: ProjectShortcutHost?, ) { val router by component.routerState.subscribeAsState() Scaffold( @@ -89,6 +92,7 @@ private fun CompactNavigation( rootSnackbar, modifier = Modifier.rootElement(scaffoldPadding), navWidth = 0.dp, + shortcutHost = shortcutHost, ) }, bottomBar = { @@ -110,6 +114,7 @@ private fun CompactNavigation( private fun RailNavigation( component: ProjectRoot, rootSnackbar: RootSnackbarHostState, + shortcutHost: ProjectShortcutHost?, ) { val router by component.routerState.subscribeAsState() val navRailState by component.navRailState.subscribeAsState() @@ -141,7 +146,13 @@ private fun RailNavigation( }, ) - ProjectRootUi(component, rootSnackbar, navRailWidth, Modifier.padding(scaffoldPadding)) + ProjectRootUi( + component, + rootSnackbar, + navRailWidth, + Modifier.padding(scaffoldPadding), + shortcutHost = shortcutHost, + ) } }, floatingActionButton = { diff --git a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootUi.kt b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootUi.kt index fe7f006d0..5610f09e2 100644 --- a/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootUi.kt +++ b/composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/projectroot/ProjectRootUi.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -26,10 +27,9 @@ import com.darkrockstudios.apps.hammer.common.compose.SetScreenCharacteristics import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdBottomBarDestination import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdNavRailDestination +import com.darkrockstudios.apps.hammer.common.compose.ProjectShortcutHost import com.darkrockstudios.apps.hammer.common.compose.rememberStrRes import com.darkrockstudios.apps.hammer.common.compose.resources.get -import com.darkrockstudios.apps.hammer.common.compose.saveAllShortcutModifier -import com.darkrockstudios.apps.hammer.common.compose.syncShortcutModifier import com.darkrockstudios.apps.hammer.common.encyclopedia.BrowseEntriesFab import com.darkrockstudios.apps.hammer.common.encyclopedia.EncyclopediaUi import com.darkrockstudios.apps.hammer.common.globalsearch.GlobalSearchUi @@ -89,21 +89,29 @@ fun ProjectRootUi( rootSnackbar: RootSnackbarHostState, navWidth: Dp = Dp.Unspecified, modifier: Modifier = Modifier, + shortcutHost: ProjectShortcutHost? = null, ) { val scope = rememberCoroutineScope() val strRes = rememberStrRes() - SetScreenCharacteristics(WIDE_SCREEN_THRESHOLD) { - FeatureContent( - modifier - .fillMaxSize() - .saveAllShortcutModifier { + if (shortcutHost != null) { + DisposableEffect(shortcutHost, component, rootSnackbar, scope, strRes) { + shortcutHost.bind( + startSync = { component.startProjectSync() }, + saveAll = { scope.launch { component.storeDirtyBuffers() rootSnackbar.showSnackbar(strRes.get(Res.string.save_all_toast)) } - } - .syncShortcutModifier { component.showProjectSync() }, + }, + ) + onDispose { shortcutHost.unbind() } + } + } + + SetScreenCharacteristics(WIDE_SCREEN_THRESHOLD) { + FeatureContent( + modifier.fillMaxSize(), component, rootSnackbar, navWidth, diff --git a/composeUi/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/compose/MatchesShortcutTest.kt b/composeUi/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/compose/MatchesShortcutTest.kt new file mode 100644 index 000000000..a7a03137d --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/compose/MatchesShortcutTest.kt @@ -0,0 +1,91 @@ +package com.darkrockstudios.apps.hammer.common.compose + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.withKeyDown +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class) +class MatchesShortcutTest { + + private fun shortcutFirings( + shortcut: Modifier.(onFired: () -> Unit) -> Modifier, + press: androidx.compose.ui.test.KeyInjectionScope.() -> Unit, + ): Int { + var fired = 0 + runComposeUiTest { + setContent { + val focusRequester = remember { FocusRequester() } + Box( + Modifier + .testTag("root") + .focusRequester(focusRequester) + .focusable() + .shortcut { fired++ } + ) + LaunchedEffect(Unit) { focusRequester.requestFocus() } + } + onNodeWithTag("root").performKeyInput(press) + } + return fired + } + + @Test + fun `bare F3 fires the sync shortcut`() { + assertEquals(1, shortcutFirings({ onKeyShortcut(Key.F3, action = it) }) { pressKey(Key.F3) }) + } + + @Test + fun `F3 with an extra modifier does not fire`() { + assertEquals( + 0, + shortcutFirings({ onKeyShortcut(Key.F3, action = it) }) { + withKeyDown(Key.CtrlLeft) { pressKey(Key.F3) } + }, + ) + assertEquals( + 0, + shortcutFirings({ onKeyShortcut(Key.F3, action = it) }) { + withKeyDown(Key.ShiftLeft) { pressKey(Key.F3) } + }, + ) + } + + @Test + fun `ctrl alt S fires save all`() { + assertEquals( + 1, + shortcutFirings({ onKeyShortcut(Key.S, ctrl = true, alt = true, action = it) }) { + withKeyDown(Key.CtrlLeft) { withKeyDown(Key.AltLeft) { pressKey(Key.S) } } + }, + ) + } + + @Test + fun `ctrl alt shift S does not fire save all`() { + assertEquals( + 0, + shortcutFirings({ onKeyShortcut(Key.S, ctrl = true, alt = true, action = it) }) { + withKeyDown(Key.CtrlLeft) { + withKeyDown(Key.AltLeft) { + withKeyDown(Key.ShiftLeft) { pressKey(Key.S) } + } + } + }, + ) + } +} diff --git a/composeUi/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/compose/ProjectShortcutHostTest.kt b/composeUi/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/compose/ProjectShortcutHostTest.kt new file mode 100644 index 000000000..94da80192 --- /dev/null +++ b/composeUi/src/desktopTest/kotlin/com/darkrockstudios/apps/hammer/common/compose/ProjectShortcutHostTest.kt @@ -0,0 +1,41 @@ +package com.darkrockstudios.apps.hammer.common.compose + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ProjectShortcutHostTest { + + @Test + fun `unbound host reports the key as unhandled`() { + val host = ProjectShortcutHost() + + assertFalse(host.startProjectSync()) + assertFalse(host.saveAllBuffers()) + } + + @Test + fun `bound host runs the actions and claims the key`() { + var syncs = 0 + var saves = 0 + val host = ProjectShortcutHost() + host.bind(startSync = { syncs++ }, saveAll = { saves++ }) + + assertTrue(host.startProjectSync()) + assertTrue(host.saveAllBuffers()) + assertEquals(1, syncs) + assertEquals(1, saves) + } + + @Test + fun `unbinding stops the actions running`() { + var syncs = 0 + val host = ProjectShortcutHost() + host.bind(startSync = { syncs++ }, saveAll = {}) + host.unbind() + + assertFalse(host.startProjectSync()) + assertEquals(0, syncs) + } +} diff --git a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/MainViewController.kt b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/MainViewController.kt index fccae15f4..0717a0e5a 100644 --- a/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/MainViewController.kt +++ b/composeUi/src/iosMain/kotlin/com/darkrockstudios/apps/hammer/common/MainViewController.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.window.ComposeUIViewController import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.darkrockstudios.apps.hammer.common.components.iosroot.IosRoot +import com.darkrockstudios.apps.hammer.common.compose.ProjectShortcutHost import com.darkrockstudios.apps.hammer.common.compose.rememberKoinInject import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore @@ -15,12 +16,15 @@ import com.darkrockstudios.apps.hammer.common.projectroot.ProjectRootScaffold import com.darkrockstudios.apps.hammer.common.projectselection.ProjectSelectScaffold import platform.UIKit.UIViewController -fun MainViewController(root: IosRoot): UIViewController = ComposeUIViewController { - HammerApp(root) -} +// shortcutHost is owned by the Swift container: UIKit key commands are the only key hook that +// does not depend on what Compose has focused, and Kotlin cannot override that category member. +fun MainViewController(root: IosRoot, shortcutHost: ProjectShortcutHost): UIViewController = + ComposeUIViewController { + HammerApp(root, shortcutHost) + } @Composable -private fun HammerApp(root: IosRoot) { +private fun HammerApp(root: IosRoot, shortcutHost: ProjectShortcutHost) { val globalSettingsStore: GlobalSettingsStore = rememberKoinInject() val settingsState by globalSettingsStore.globalSettingsUpdates .collectAsState(initial = globalSettingsStore.globalSettings) @@ -42,6 +46,7 @@ private fun HammerApp(root: IosRoot) { ProjectRootScaffold( component = destination.component, onCloseRequest = { root.closeProject() }, + shortcutHost = shortcutHost, ) null -> Unit diff --git a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt index d9d88af7a..5ea5bccdf 100644 --- a/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt +++ b/desktop/src/jvmMain/kotlin/com/darkrockstudios/apps/hammer/desktop/ProjectEditorWindow.kt @@ -41,9 +41,11 @@ import com.darkrockstudios.apps.hammer.common.AppCloseManager import com.darkrockstudios.apps.hammer.common.components.projectroot.CloseConfirm import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectRoot import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectRootComponent +import com.darkrockstudios.apps.hammer.common.compose.ProjectShortcutHost import com.darkrockstudios.apps.hammer.common.compose.Ui import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMonoLabel import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdNavRail +import com.darkrockstudios.apps.hammer.common.compose.matchesShortcut import com.darkrockstudios.apps.hammer.common.compose.rememberMainDispatcher import com.darkrockstudios.apps.hammer.common.compose.rememberRootSnackbarHostState import com.darkrockstudios.apps.hammer.common.compose.resources.get @@ -58,6 +60,7 @@ import com.darkrockstudios.apps.hammer.project_window_menu_file import com.darkrockstudios.apps.hammer.project_window_menu_item_close import com.darkrockstudios.apps.hammer.project_window_menu_item_exit import com.darkrockstudios.apps.hammer.project_window_title +import com.darkrockstudios.apps.hammer.save_all_toast import dev.nucleusframework.application.NucleusApplicationScope import dev.nucleusframework.window.material.MaterialDecoratedWindow import dev.nucleusframework.window.material.MaterialTitleBar @@ -105,11 +108,21 @@ internal fun NucleusApplicationScope.ProjectEditorWindow( } val windowTitle = Res.string.project_window_title.get(projectDef.name) + val shortcutHost = remember { ProjectShortcutHost() } + MaterialDecoratedWindow( title = windowTitle, state = windowState, icon = painterResource("icon.png"), onCloseRequest = { onRequestClose(component, app, ApplicationState.CloseType.Application) }, + // These two run pre-focus so a focused editor can't swallow them. + onPreviewKeyEvent = { event -> + when { + event.matchesShortcut(Key.F3) -> shortcutHost.startProjectSync() + event.matchesShortcut(Key.S, ctrl = true, alt = true) -> shortcutHost.saveAllBuffers() + else -> false + } + }, onKeyEvent = { event -> when { event.key == Key.Escape && event.type == KeyEventType.KeyUp -> { @@ -153,7 +166,7 @@ internal fun NucleusApplicationScope.ProjectEditorWindow( // Tao windows are their own ComposeScene: locals provided outside the // window (AppTheme in Main.kt) don't reach this content, so re-apply. AppTheme(useDarkTheme = darkMode, settings = settings) { - AppContent(component) + AppContent(component, shortcutHost) LaunchedEffect(closeRequest) { if (closeRequest != ApplicationState.CloseType.None) { @@ -263,7 +276,7 @@ private fun FrameWindowScope.EditorMenuBar( } @Composable -private fun AppContent(component: ProjectRoot) { +private fun AppContent(component: ProjectRoot, shortcutHost: ProjectShortcutHost) { val router by component.routerState.subscribeAsState() val themeState by component.projectTheme.subscribeAsState() val navRailState by component.navRailState.subscribeAsState() @@ -288,7 +301,7 @@ private fun AppContent(component: ProjectRoot) { }, ) - ProjectRootUi(component, rootSnackbar) + ProjectRootUi(component, rootSnackbar, shortcutHost = shortcutHost) } SnackbarHost( @@ -305,4 +318,4 @@ private fun AppContent(component: ProjectRoot) { } } } -} \ No newline at end of file +} diff --git a/docs/KEYBOARD-SHORTCUTS.md b/docs/KEYBOARD-SHORTCUTS.md index 9bdefc829..4af794a2f 100644 --- a/docs/KEYBOARD-SHORTCUTS.md +++ b/docs/KEYBOARD-SHORTCUTS.md @@ -1,6 +1,7 @@ # Keyboard Shortcuts -Desktop-only. `Ctrl` and `Cmd` are interchangeable everywhere below: `onKeyShortcut` +Desktop, plus the two project shortcuts on Android and iOS with a hardware keyboard. +`Ctrl` and `Cmd` are interchangeable everywhere below: `onKeyShortcut` ( `composeUi/src/commonMain/kotlin/com/darkrockstudios/apps/hammer/common/compose/ShortcutModifiers.kt`) treats `isCtrlPressed || isMetaPressed` as a single "ctrl" modifier, so these are not @@ -17,10 +18,29 @@ macOS-specific `Cmd` bindings, they work with either key on every platform. ## Project (while a project is open) -| Shortcut | Action | Source | -|--------------|----------------------------------------------|--------------------------------------------------| -| `Ctrl+Alt+S` | Save all dirty buffers (scenes, notes, etc.) | `saveAllShortcutModifier` via `ProjectRootUi.kt` | -| `F3` | Start project sync | `syncShortcutModifier` via `ProjectRootUi.kt` | +These two work on all three platforms, each through the host hook that does not depend on +what Compose has focused. Compose routes key events along the focus path, so a screen with +nothing focused never sees them, which is why none of these are `Modifier` shortcuts. + +| Platform | Hook | +|----------|---------------------------------------------------------------| +| Desktop | `ProjectEditorWindow.kt` window `onPreviewKeyEvent` | +| Android | `ProjectRootActivity.dispatchKeyEvent` | +| iOS | `ShortcutHostController.keyCommands` in `ComposeContainer.swift` | + +Android and iOS reach the shared action through `ProjectShortcutHost`, which +`ProjectRootScaffold` binds while the project UI is composed. The application shortcuts +above use the window's `onKeyEvent` instead and yield to a focused component that consumes +the key. Modifier shortcuts (the editor ones below) only fire when focus sits inside the +composable they are attached to. + +Modifiers match exactly: `F3` means F3 with nothing held, so `Ctrl+F3` and `Shift+F3` do +not start a sync. On iOS the save-all chord is `Cmd+Opt+S` or `Ctrl+Opt+S`. + +| Shortcut | Action | +|--------------|----------------------------------------------| +| `Ctrl+Alt+S` | Save all dirty buffers (scenes, notes, etc.) | +| `F3` | Start project sync (server-linked only) | ## Editors (scene, note, timeline event, story idea) diff --git a/ios/ios/ComposeContainer.swift b/ios/ios/ComposeContainer.swift index 72a13352d..ce9772068 100644 --- a/ios/ios/ComposeContainer.swift +++ b/ios/ios/ComposeContainer.swift @@ -13,9 +13,64 @@ struct ComposeContainer: UIViewControllerRepresentable { let root: IosRoot func makeUIViewController(context: Context) -> UIViewController { - MainViewControllerKt.MainViewController(root: root) + let shortcutHost = ProjectShortcutHost() + let compose = MainViewControllerKt.MainViewController(root: root, shortcutHost: shortcutHost) + return ShortcutHostController(shortcutHost: shortcutHost, content: compose) } func updateUIViewController(_ uiViewController: UIViewController, context: Context) { } } + +/// Compose routes key events along the focus path, so a screen with nothing focused never sees +/// them. UIKit key commands travel the responder chain instead and always reach this controller. +final class ShortcutHostController: UIViewController { + + /// UIKit reports function keys as these private-use characters (NSF3FunctionKey). + private static let f3Input = String(UnicodeScalar(0xF706)!) + + private let shortcutHost: ProjectShortcutHost + private let content: UIViewController + + init(shortcutHost: ProjectShortcutHost, content: UIViewController) { + self.shortcutHost = shortcutHost + self.content = content + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not used") + } + + override func viewDidLoad() { + super.viewDidLoad() + + addChild(content) + content.view.frame = view.bounds + content.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(content.view) + content.didMove(toParent: self) + } + + override var childForStatusBarStyle: UIViewController? { content } + + override var childForStatusBarHidden: UIViewController? { content } + + override var canBecomeFirstResponder: Bool { true } + + override var keyCommands: [UIKeyCommand]? { + [ + UIKeyCommand(input: Self.f3Input, modifierFlags: [], action: #selector(startSync)), + UIKeyCommand(input: "s", modifierFlags: [.command, .alternate], action: #selector(saveAll)), + UIKeyCommand(input: "s", modifierFlags: [.control, .alternate], action: #selector(saveAll)), + ] + } + + @objc private func startSync() { + _ = shortcutHost.startProjectSync() + } + + @objc private func saveAll() { + _ = shortcutHost.saveAllBuffers() + } +}