diff --git a/CHANGELOG.md b/CHANGELOG.md index 586a2a9ad..adb44f0fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Quick Switcher opens again on macOS Sequoia. Since 0.51.0 the panel came up invisible on macOS 15, and the toolbar button and keyboard shortcut appeared to do nothing. (#1806) +- Quick Switcher keyboard navigation (Ctrl-J/K and arrow shortcuts) no longer goes dead after the switcher has been opened and closed repeatedly. (#1806) - Restored table tabs no longer reload all at once or flood failure dialogs on launch. Only the frontmost tab loads immediately; other restored tabs load when you switch to them, and a load failure now shows inline in the tab instead of a dialog. (#1796) - The plugin list no longer goes stale. The app now checks the plugin registry for changes at launch, when the plugin browser opens, and before reporting a plugin as missing, so newly published plugins show up and install right away. A registry that cannot be reached now reports a connection problem instead of "Plugin not found". (#1799) - Dropping a materialized view or foreign table from the sidebar now generates the correct DROP statement instead of DROP TABLE, and drop and truncate statements are schema-qualified. ClickHouse now lists materialized views as their own sidebar section and drops them with the DROP VIEW syntax it requires. (#1800) diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index 87e55a86d..7f2a72548 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -6,13 +6,16 @@ import AppKit import SwiftUI -internal final class QuickSwitcherPanel: NSPanel { - var onCancel: (() -> Void)? +private let fallbackScreenFrame = NSRect(x: 0, y: 0, width: 1_280, height: 800) - init(contentView: NSView) { +internal final class QuickSwitcherPanel: NSPanel { + init(hostingController: NSHostingController) { + hostingController.sizingOptions = [] + let proposal = NSScreen.main?.visibleFrame.size ?? fallbackScreenFrame.size + let contentSize = hostingController.sizeThatFits(in: proposal) super.init( - contentRect: NSRect(origin: .zero, size: contentView.fittingSize), - styleMask: [.borderless, .fullSizeContentView], + contentRect: NSRect(origin: .zero, size: contentSize), + styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], backing: .buffered, defer: false ) @@ -23,15 +26,22 @@ internal final class QuickSwitcherPanel: NSPanel { backgroundColor = .clear hasShadow = true isMovableByWindowBackground = false + isReleasedWhenClosed = false animationBehavior = .utilityWindow - self.contentView = contentView + contentViewController = hostingController + setContentSize(contentSize) } override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } + override func resignKey() { + super.resignKey() + close() + } + override func cancelOperation(_ sender: Any?) { - onCancel?() + close() } } @@ -52,17 +62,20 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { func present(_ content: some View, over parentWindow: NSWindow?) { dismiss() - let hostingView = NSHostingView(rootView: content) - hostingView.sizingOptions = .preferredContentSize + let sizeReportingContent = content.onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { [weak self] size in + self?.contentSizeDidChange(size) + } + let hostingController = NSHostingController(rootView: sizeReportingContent) - let panel = QuickSwitcherPanel(contentView: hostingView) + let panel = QuickSwitcherPanel(hostingController: hostingController) panel.delegate = self - panel.onCancel = { [weak self] in self?.dismiss() } self.panel = panel let reference = parentWindow?.frame ?? NSScreen.main?.visibleFrame - ?? NSRect(x: 0, y: 0, width: 1_280, height: 800) + ?? fallbackScreenFrame anchor = Anchor( centerX: reference.midX, top: reference.maxY - reference.height * Self.topOffsetRatio @@ -72,16 +85,13 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { } func dismiss() { - guard let panel else { return } - panel.delegate = nil - panel.onCancel = nil - self.panel = nil - anchor = nil - panel.orderOut(nil) + panel?.close() } - func windowDidResignKey(_ notification: Notification) { - dismiss() + func windowWillClose(_ notification: Notification) { + panel?.contentViewController = nil + panel = nil + anchor = nil } func windowDidResize(_ notification: Notification) { @@ -90,6 +100,11 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { panel.invalidateShadow() } + private func contentSizeDidChange(_ size: CGSize) { + guard let panel, size.width > 0, size.height > 0, panel.frame.size != size else { return } + panel.setContentSize(size) + } + private func applyAnchor(to panel: QuickSwitcherPanel) { guard let anchor else { return } let size = panel.frame.size diff --git a/TableProTests/Views/QuickSwitcherPanelControllerTests.swift b/TableProTests/Views/QuickSwitcherPanelControllerTests.swift index d650d1478..e9c40de85 100644 --- a/TableProTests/Views/QuickSwitcherPanelControllerTests.swift +++ b/TableProTests/Views/QuickSwitcherPanelControllerTests.swift @@ -18,7 +18,7 @@ struct QuickSwitcherPanelControllerTests { controller.dismiss() } - @Test("dismiss hides the panel") + @Test("dismiss closes the panel and clears the presented state") func dismissHidesPanel() { let controller = QuickSwitcherPanelController() controller.present(Text(verbatim: "content"), over: nil) @@ -36,29 +36,55 @@ struct QuickSwitcherPanelControllerTests { #expect(controller.isPresented == false) } - @Test("losing key status dismisses the panel") - func resignKeyDismissesPanel() { + @Test("panel resolves a non-zero frame from its content before showing") + func panelResolvesNonZeroFrame() { + let hostingController = NSHostingController(rootView: Text(verbatim: "content")) + let panel = QuickSwitcherPanel(hostingController: hostingController) + #expect(panel.frame.width > 0) + #expect(panel.frame.height > 0) + panel.close() + } + + @Test("dismiss after the panel already closed is a safe no-op") + func dismissAfterAlreadyClosedIsNoOp() { let controller = QuickSwitcherPanelController() controller.present(Text(verbatim: "content"), over: nil) - controller.windowDidResignKey(Notification(name: NSWindow.didResignKeyNotification)) + controller.dismiss() + controller.dismiss() #expect(controller.isPresented == false) } - @Test("panel cannot become main but can become key") + @Test("panel can become key but not main") func panelKeyAndMainBehavior() { - let panel = QuickSwitcherPanel(contentView: NSView()) + let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content"))) #expect(panel.canBecomeKey) #expect(panel.canBecomeMain == false) - panel.orderOut(nil) + panel.close() } - @Test("escape on the panel invokes onCancel") - func escapeInvokesOnCancel() { - let panel = QuickSwitcherPanel(contentView: NSView()) - var cancelled = false - panel.onCancel = { cancelled = true } + @Test("resigning key closes the panel") + func resignKeyClosesPanel() { + let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content"))) + panel.makeKeyAndOrderFront(nil) + #expect(panel.isVisible) + panel.resignKey() + #expect(panel.isVisible == false) + } + + @Test("escape closes the panel") + func escapeClosesPanel() { + let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content"))) + panel.makeKeyAndOrderFront(nil) + #expect(panel.isVisible) panel.cancelOperation(nil) - #expect(cancelled) - panel.orderOut(nil) + #expect(panel.isVisible == false) + } + + @Test("panel uses a nonactivating borderless style mask") + func panelStyleMask() { + let panel = QuickSwitcherPanel(hostingController: NSHostingController(rootView: Text(verbatim: "content"))) + #expect(panel.styleMask.contains(.nonactivatingPanel)) + #expect(panel.styleMask.contains(.borderless)) + panel.close() } }