From 2f265fd847d6c13bd081ae4f9a87bd170b38674c Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 2 May 2026 23:50:51 -0700 Subject: [PATCH] ios: mac catalyst kb shortcuts --- ios/Apps/Sybil/Sources/SybilApp.swift | 3 + .../Sybil/Sources/Sybil/SplitView.swift | 78 +++++++++++++++++++ .../Sybil/Sources/Sybil/SybilViewModel.swift | 28 +++++++ 3 files changed, 109 insertions(+) diff --git a/ios/Apps/Sybil/Sources/SybilApp.swift b/ios/Apps/Sybil/Sources/SybilApp.swift index 190c14a..c812842 100644 --- a/ios/Apps/Sybil/Sources/SybilApp.swift +++ b/ios/Apps/Sybil/Sources/SybilApp.swift @@ -9,5 +9,8 @@ struct SybilApp: App WindowGroup { SplitView() } + .commands { + SybilCommands() + } } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift index c46ad29..7d187c2 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SplitView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SplitView.swift @@ -7,6 +7,29 @@ public struct SplitView: View { @State private var shouldRefreshOnForeground = false @State private var composerFocusRequest = 0 + private var keyboardActions: SybilKeyboardActions? { + guard !viewModel.isCheckingSession, viewModel.isAuthenticated else { + return nil + } + + return SybilKeyboardActions( + newChat: { + viewModel.startNewChat() + composerFocusRequest += 1 + }, + newSearch: { + viewModel.startNewSearch() + composerFocusRequest += 1 + }, + previousConversation: { + viewModel.selectPreviousSidebarItem() + }, + nextConversation: { + viewModel.selectNextSidebarItem() + } + ) + } + @MainActor public init() { SybilFontRegistry.registerIfNeeded() SybilTheme.applySystemAppearance() @@ -41,6 +64,7 @@ public struct SplitView: View { } .font(.sybil(.body)) .preferredColorScheme(.dark) + .focusedSceneValue(\.sybilKeyboardActions, keyboardActions) .task { await viewModel.bootstrap() } @@ -67,3 +91,57 @@ public struct SplitView: View { } } } + +public struct SybilCommands: Commands { + @FocusedValue(\.sybilKeyboardActions) private var keyboardActions + + public init() {} + + public var body: some Commands { + CommandGroup(replacing: .newItem) { + Button("New Chat") { + keyboardActions?.newChat() + } + .keyboardShortcut("n", modifiers: .command) + .disabled(keyboardActions == nil) + + Button("New Search") { + keyboardActions?.newSearch() + } + .keyboardShortcut("n", modifiers: [.command, .shift]) + .disabled(keyboardActions == nil) + } + + CommandMenu("Conversation") { + Button("Previous Conversation") { + keyboardActions?.previousConversation() + } + .keyboardShortcut("[", modifiers: .command) + .disabled(keyboardActions == nil) + + Button("Next Conversation") { + keyboardActions?.nextConversation() + } + .keyboardShortcut("]", modifiers: .command) + .disabled(keyboardActions == nil) + } + } +} + +private struct SybilKeyboardActions { + var newChat: () -> Void + var newSearch: () -> Void + var previousConversation: () -> Void + var nextConversation: () -> Void +} + +private struct SybilKeyboardActionsKey: FocusedValueKey { + typealias Value = SybilKeyboardActions +} + +private extension FocusedValues { + var sybilKeyboardActions: SybilKeyboardActions? { + get { self[SybilKeyboardActionsKey.self] } + set { self[SybilKeyboardActionsKey.self] = newValue } + } +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index d19f699..7c1ed46 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -443,6 +443,34 @@ final class SybilViewModel { } } + func selectPreviousSidebarItem() { + selectAdjacentSidebarItem(offset: -1) + } + + func selectNextSidebarItem() { + selectAdjacentSidebarItem(offset: 1) + } + + private func selectAdjacentSidebarItem(offset: Int) { + let items = sidebarItems + guard !items.isEmpty else { + return + } + + let currentIndex = selectedItem.flatMap { selection in + items.firstIndex { $0.selection == selection } + } + let startingIndex = currentIndex ?? (offset < 0 ? items.count : -1) + let nextIndex = (startingIndex + offset + items.count) % items.count + let nextSelection = items[nextIndex].selection + + guard draftKind != nil || selectedItem != nextSelection else { + return + } + + select(nextSelection) + } + func deleteItem(_ selection: SidebarSelection) async { guard isAuthenticated else { return