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

* 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:
Adam Brown 2026-08-07 00:45:59 -07:00 committed by GitHub
parent bd2e3a9666
commit 1c856dc32b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 380 additions and 48 deletions

View file

@ -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 {

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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
}
}

View file

@ -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)

View file

@ -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 = {

View file

@ -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,

View file

@ -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) }
}
}
},
)
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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) {
}
}
}
}
}

View file

@ -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)

View file

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