eleven / messenger Public

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>
Paul Campbell 132b48a93ce22a90cd7072eb4a6acedbf5bea18f parent 3f8d2a1
2 files changed, +34 −3
  • ios/LChatCore/Sources/LChatCore/CreateSpaceView.swift +15 −3
  • ios/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)