mirror of
https://github.com/Darkrock-Studios/hammer-editor.git
synced 2026-08-14 20:13:14 +00:00
Fix the F3 sync and Ctrl+Alt+S shortcuts, and give them to Android and iOS (#865)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* 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.
This commit is contained in:
parent
bd2e3a9666
commit
1c856dc32b
14 changed files with 380 additions and 48 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<ProjectRoot.ModalDestination.ProjectSync>(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<ProjectRoot.ModalDestination.None>(comp.modalRouterState.value.child?.instance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Global search modal opens and dismisses`() = runTest(mainTestDispatcher) {
|
||||
val comp = newComponent()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue