mac: Esc closes the create-space takeover (only while backing out is safe) (#505)
The create-a-space instrument takes over the whole window, so it never got a sheet's free Esc-to-dismiss. A local NSEvent monitor (installed only while the takeover is up — the webview behind holds first responder, so SwiftUI key hooks never fire here) routes Esc through the model's new requestCancel(): the same pre-snap gate as the Cancel button, so the two can never disagree — once the create fires, the space is real and Esc does nothing. The takeover owns the key outright while visible; it never leaks to the webview beneath.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+34
−3
ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift+15 −3ios/LChatMac/RootView.swift+19 −0
diff --git a/ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift b/ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift| index 281b4ff..4ba647a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift |
| @@ -86,6 +86,18 @@ public final class CreateSpaceModel: ObservableObject, Identifiable { |
| public var busy: Bool { creating } |
| + // Backing out is only offered before the snap — once the create has fired |
| + // the space is real, and it opens on its own moments later. The ONE gate |
| + // shared by the Cancel button and the Mac Esc key, so they can never |
| + // disagree about when leaving is safe. |
| + public var canCancel: Bool { phase == .slide && stage == .slide } |
| + |
| + // Cancel with the gate folded in — what a host's Esc handler calls (the |
| + // raw `cancel` closure stays internal, reachable only past the gate). |
| + public func requestCancel() { |
| + if canCancel { cancel() } |
| + } |
| + |
| /// Start the 60Hz sim driver. Timer fires on the main run loop (.common, |
| /// so it keeps ticking through gesture tracking), OUTSIDE view evaluation — |
| /// stepping here may touch @Published state freely. |
| @@ -381,10 +393,10 @@ public struct CreateSpaceView: View { |
| .ignoresSafeArea() |
| } |
| } |
| - // Backing out is only offered before the snap — once the create has |
| - // fired the space is real, and it opens on its own moments later. |
| + // Backing out is only offered before the snap (canCancel — the same |
| + // gate the Mac Esc key uses). |
| .overlay(alignment: .topLeading) { |
| - if model.phase == .slide, model.stage == .slide { |
| + if model.canCancel { |
| Button("Cancel") { model.cancel() } |
| .buttonStyle(.plain) |
| .foregroundStyle(.secondary) |
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift| index d16ffbd..5633ac7 100644 |
| --- a/ios/LChatMac/RootView.swift |
| +++ b/ios/LChatMac/RootView.swift |
| @@ -20,6 +20,10 @@ struct RootView: View { |
| // The create-a-space takeover (the rail's + / File ▸ New Space, via |
| // app.createSpaceTick). Non-nil = the instrument owns the window. |
| @State private var createModel: CreateSpaceModel? |
| + // Esc-to-close for the takeover, installed only while it's up. A local |
| + // NSEvent monitor, not a SwiftUI key hook: the webview behind still holds |
| + // first responder, so .onKeyPress/.onExitCommand never fire here. |
| + @State private var createEscMonitor: Any? |
| // The goes-to-eleven create flow. The WHOLE pipeline (provision → claim → |
| // setup_complete) runs inside `create`, under "Building your space" — the |
| @@ -70,6 +74,21 @@ struct RootView: View { |
| .frame(maxWidth: .infinity, maxHeight: .infinity) |
| .background(Color(nsColor: .windowBackgroundColor)) |
| .transition(.opacity) |
| + // Esc backs out exactly where the Cancel button would |
| + // (model.canCancel — never mid-create). The takeover owns |
| + // the key while it's up: even when cancelling is gated |
| + // off, Esc must not leak to the webview underneath. |
| + .onAppear { |
| + createEscMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in |
| + guard event.keyCode == 53 else { return event } // Esc |
| + model.requestCancel() |
| + return nil |
| + } |
| + } |
| + .onDisappear { |
| + if let createEscMonitor { NSEvent.removeMonitor(createEscMonitor) } |
| + createEscMonitor = nil |
| + } |
| } |
| } |
| .animation(.easeOut(duration: 0.18), value: createModel == nil) |