Skip to content

Commit e8c8b72

Browse files
committed
Build first-success onboarding with Metal keyboard journey
1 parent d69f04a commit e8c8b72

63 files changed

Lines changed: 9690 additions & 58 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Package.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ let package = Package(
185185
],
186186
path: "Sources/KeyPathAppKit",
187187
exclude: [
188-
"InstallationWizard/README.md"
188+
"InstallationWizard/README.md",
189+
// Compiled into default.metallib by CompileKeyboardStageMetal.
190+
"UI/KeyboardStage/Metal/KeyboardStage.metal"
189191
],
190192
resources: [
191193
.process("Resources")
@@ -194,7 +196,12 @@ let package = Package(
194196
.swiftLanguageMode(.v6)
195197
],
196198
linkerSettings: [
197-
.linkedFramework("IOKit")
199+
.linkedFramework("IOKit"),
200+
.linkedFramework("Metal"),
201+
.linkedFramework("MetalKit")
202+
],
203+
plugins: [
204+
.plugin(name: "CompileKeyboardStageMetal")
198205
]
199206
),
200207
// Main executable entry point
@@ -319,6 +326,11 @@ let package = Package(
319326
.swiftLanguageMode(.v6)
320327
]
321328
),
329+
.plugin(
330+
name: "CompileKeyboardStageMetal",
331+
capability: .buildTool(),
332+
path: "Plugins/CompileKeyboardStageMetal"
333+
),
322334
// Tests
323335
.testTarget(
324336
name: "KeyPathSmokeTests",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import Foundation
2+
import PackagePlugin
3+
4+
@main
5+
struct CompileKeyboardStageMetal: BuildToolPlugin {
6+
func createBuildCommands(
7+
context: PluginContext,
8+
target: any Target
9+
) async throws -> [Command] {
10+
let shader = target.directoryURL
11+
.appendingPathComponent("UI/KeyboardStage/Metal/KeyboardStage.metal")
12+
let library = context.pluginWorkDirectoryURL
13+
.appendingPathComponent("default.metallib")
14+
let xcrun = URL(fileURLWithPath: "/usr/bin/xcrun")
15+
16+
return [
17+
.buildCommand(
18+
displayName: "Compile KeyboardStage default.metallib",
19+
executable: xcrun,
20+
arguments: [
21+
"-sdk", "macosx", "metal",
22+
shader.path,
23+
"-o", library.path,
24+
],
25+
inputFiles: [shader],
26+
outputFiles: [library]
27+
),
28+
]
29+
}
30+
}

Sources/KeyPathAppKit/App.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
774774
return
775775
}
776776

777-
let initialPage = targetPage ?? resolveWizardInitialPage()
777+
// The one-time welcome gate has a QA override. Use an explicit welcome
778+
// target so the wizard's health-summary routing cannot bypass it.
779+
let initialPage = UserDefaults.standard.bool(forKey: WizardWelcomeGate.forceWelcomeKey)
780+
? .welcome
781+
: targetPage ?? resolveWizardInitialPage()
778782
WizardWindowController.shared.showWindow(
779783
initialPage: initialPage,
780784
kanataViewModel: vm,

Sources/KeyPathAppKit/InstallationWizard/WizardWindowController.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ final class WizardWindowController {
2323
private var hostingView: NSView?
2424
private var windowDelegate: WizardWindowDelegate?
2525
private var onDismiss: (() -> Void)?
26+
private var onboardingViewModel: KanataViewModel?
2627
private var sizeObserver: NSObjectProtocol?
2728
private var resizeDebounceWorkItem: DispatchWorkItem?
2829

@@ -35,6 +36,9 @@ final class WizardWindowController {
3536
onDismiss: (() -> Void)? = nil
3637
) {
3738
self.onDismiss = onDismiss
39+
if let kanataViewModel {
40+
onboardingViewModel = kanataViewModel
41+
}
3842

3943
// If window already exists and is visible, just bring it to front
4044
if let existingWindow = window, existingWindow.isVisible {
@@ -49,7 +53,12 @@ final class WizardWindowController {
4953

5054
AppLogger.shared.log("🔮 [WizardWindow] Opening wizard window (initialPage: \(initialPage?.displayName ?? "nil"))")
5155

52-
let wizardView = InstallationWizardView(initialPage: initialPage)
56+
let wizardView = InstallationWizardView(
57+
initialPage: initialPage,
58+
onFirstSuccess: { [weak self] in
59+
self?.showFirstSuccessOnboarding()
60+
}
61+
)
5362

5463
// Create hosting view - let SwiftUI determine ideal height
5564
let styledView = wizardView
@@ -211,6 +220,17 @@ final class WizardWindowController {
211220
onDismiss = nil
212221
}
213222

223+
private func showFirstSuccessOnboarding() {
224+
// Let AppKit complete the wizard close before presenting a separate
225+
// learning panel. This keeps the two moments spatially distinct.
226+
let viewModel = onboardingViewModel ?? (NSApp.delegate as? AppDelegate)?.viewModel
227+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
228+
FirstSuccessOnboardingWindowController.show(
229+
kanataViewModel: viewModel
230+
)
231+
}
232+
}
233+
214234
/// Check if the wizard window is currently visible
215235
var isVisible: Bool {
216236
window?.isVisible ?? false

Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -415,14 +415,7 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate {
415415
// Wire up RuleCollectionsManager callbacks
416416
ruleCollectionsManager.onRulesChanged = { [weak self] in
417417
guard let self else { return }
418-
_ = await triggerConfigReload()
419-
await MainActor.run {
420-
self.notifyStateChanged()
421-
}
422-
// Notify overlay to rebuild layer mapping
423-
AppLogger.shared.debug("🔔 [RuntimeCoordinator] Posting kanataConfigChanged notification")
424-
425-
NotificationCenter.default.post(name: .kanataConfigChanged, object: nil)
418+
_ = await applyPersistedRuleChanges()
426419
}
427420
ruleCollectionsManager.onLayerChanged = { [weak self] layerName in
428421
self?.currentLayerName = layerName
@@ -1176,6 +1169,22 @@ public class RuntimeCoordinator: SaveCoordinatorDelegate {
11761169
await configReloadCoordinator.triggerConfigReload()
11771170
}
11781171

1172+
/// Apply rule changes that have already been validated and persisted.
1173+
///
1174+
/// Most rule saves reach this through `onRulesChanged`. Transactions that
1175+
/// deliberately suppress that callback so they can verify the live result
1176+
/// use this same path explicitly, keeping state and overlay notifications
1177+
/// consistent without issuing a duplicate reload.
1178+
func applyPersistedRuleChanges() async -> ReloadResult {
1179+
let result = await triggerConfigReload()
1180+
notifyStateChanged()
1181+
AppLogger.shared.debug(
1182+
"🔔 [RuntimeCoordinator] Posting kanataConfigChanged notification"
1183+
)
1184+
NotificationCenter.default.post(name: .kanataConfigChanged, object: nil)
1185+
return result
1186+
}
1187+
11791188
/// TCP-based config reload (no authentication required - see ADR-013)
11801189
func triggerTCPReload() async -> TCPReloadResult {
11811190
await configReloadCoordinator.triggerTCPReload()

0 commit comments

Comments
 (0)