diff --git a/Snapzy/Features/Recording/RecordingCoordinator.swift b/Snapzy/Features/Recording/RecordingCoordinator.swift index 9270d622c..a3ece5dc4 100644 --- a/Snapzy/Features/Recording/RecordingCoordinator.swift +++ b/Snapzy/Features/Recording/RecordingCoordinator.swift @@ -248,6 +248,12 @@ final class RecordingCoordinator: ObservableObject { "isActive": "\(isActive)", "recorderState": "\(recorder.state)", ]) + + guard recorder.state != .idle else { + cleanup() + return + } + Task { await recorder.cancelRecording() cleanup() @@ -281,6 +287,7 @@ final class RecordingCoordinator: ObservableObject { toolbarWindow = toolbar showRegionOverlay(for: rect, interactionEnabled: captureMode != .application) + toolbar.bringToFrontForInteraction() setupEscapeMonitors() } diff --git a/Snapzy/Features/Recording/RecordingToolbarWindow.swift b/Snapzy/Features/Recording/RecordingToolbarWindow.swift index 242ceb29f..3dbf3b975 100644 --- a/Snapzy/Features/Recording/RecordingToolbarWindow.swift +++ b/Snapzy/Features/Recording/RecordingToolbarWindow.swift @@ -6,9 +6,36 @@ // import AppKit +import Carbon.HIToolbox import Combine import SwiftUI +@MainActor +private final class RecordingToolbarHostingView: NSHostingView { + var onEscape: (() -> Void)? + + override var acceptsFirstResponder: Bool { + true + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func keyDown(with event: NSEvent) { + guard event.keyCode == UInt16(kVK_Escape) else { + super.keyDown(with: event) + return + } + + onEscape?() + } + + override func cancelOperation(_ sender: Any?) { + onEscape?() + } +} + enum RecordingToolbarMode { case preRecord case recording @@ -150,11 +177,11 @@ final class RecordingToolbarState: ObservableObject { // MARK: - Toolbar Window @MainActor -final class RecordingToolbarWindow: NSWindow { +final class RecordingToolbarWindow: NSPanel { private var anchorRect: CGRect private var mode: RecordingToolbarMode = .preRecord - private var hostingView: NSHostingView? + private var hostingView: RecordingToolbarHostingView? private var effectView: NSVisualEffectView? private var cachedContentSize: CGSize? @@ -234,7 +261,9 @@ final class RecordingToolbarWindow: NSWindow { level = .popUpMenu collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] hasShadow = true + isFloatingPanel = true isReleasedWhenClosed = false + hidesOnDeactivate = false // Apply theme appearance at window level (mirrors AnnotateWindow.applyTheme) appearance = ThemeManager.shared.nsAppearance @@ -281,7 +310,11 @@ final class RecordingToolbarWindow: NSWindow { private func setContent(_ view: AnyView) { let themedView = view.preferredColorScheme(ThemeManager.shared.systemAppearance) - let hosting = NSHostingView(rootView: AnyView(themedView)) + let hosting = RecordingToolbarHostingView(rootView: AnyView(themedView)) + hosting.onEscape = { [weak self] in + guard self?.mode == .preRecord else { return } + self?.onCancel?() + } hosting.translatesAutoresizingMaskIntoConstraints = false // NSVisualEffectView provides native wallpaper-tinted material backing, @@ -339,10 +372,38 @@ final class RecordingToolbarWindow: NSWindow { /// Position and order the window to the front (initial show only). private func showBelowRect(_ rect: CGRect) { positionBelowRect(rect) + bringToFrontForInteraction() + } + + func bringToFrontForInteraction() { + NSApp.activate(ignoringOtherApps: true) orderFrontRegardless() + makeKey() + if let hostingView { + makeFirstResponder(hostingView) + } } override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + override func keyDown(with event: NSEvent) { + guard mode == .preRecord, event.keyCode == UInt16(kVK_Escape) else { + super.keyDown(with: event) + return + } + + onCancel?() + } + + override func cancelOperation(_ sender: Any?) { + guard mode == .preRecord else { + super.cancelOperation(sender) + return + } + + onCancel?() + } func updateAnchorRect(_ rect: CGRect) { anchorRect = rect diff --git a/SnapzyTests/Services/Shortcuts/RecordingToolbarShortcutsTests.swift b/SnapzyTests/Services/Shortcuts/RecordingToolbarShortcutsTests.swift index b48f7f0b6..b75f92abf 100644 --- a/SnapzyTests/Services/Shortcuts/RecordingToolbarShortcutsTests.swift +++ b/SnapzyTests/Services/Shortcuts/RecordingToolbarShortcutsTests.swift @@ -15,6 +15,45 @@ import XCTest final class RecordingToolbarShortcutsTests: XCTestCase { + @MainActor + func testPreRecordToolbarWindow_usesActivatingPanelForButtonClicks() { + let window = RecordingToolbarWindow(anchorRect: CGRect(x: 0, y: 0, width: 640, height: 360)) + defer { window.close() } + + XCTAssertTrue(window.styleMask.contains(.borderless)) + XCTAssertFalse(window.styleMask.contains(.nonactivatingPanel)) + XCTAssertTrue(window.canBecomeKey) + XCTAssertFalse(window.canBecomeMain) + } + + @MainActor + func testPreRecordToolbarWindow_escapeInvokesCancel() throws { + let window = RecordingToolbarWindow(anchorRect: CGRect(x: 0, y: 0, width: 640, height: 360)) + defer { window.close() } + + var didCancel = false + window.onCancel = { + didCancel = true + } + + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: UInt16(kVK_Escape) + )) + + window.keyDown(with: event) + + XCTAssertTrue(didCancel) + } + // MARK: - GlobalShortcutKind & ShortcutAction Case Matching func testRecordingToolbarShortcuts_kindsArePresent() {