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() + } +}