From 15544c70fb9171e561b931620642993704b51312 Mon Sep 17 00:00:00 2001 From: onemannoplan Date: Sun, 18 Oct 2026 16:00:35 -0500 Subject: [PATCH] fix(tabs): open files as tabs in full screen and Split View Opening a file while QuickMD was in full screen or Split View gave the tab group a full-screen Space of its own: the Split View broke, half the screen was black and the menu bar could not be revealed. WindowConfigurator's callback runs only after the new window is on screen (and, in full screen, already in a Space of its own), so its late addTabbedWindow moved a window mid-transition. It also renamed every window's tabbingIdentifier, so AppKit's own tabbing (on by default in full screen) never matched the new window to the existing one. - Keep SwiftUI's shared tabbingIdentifier and set AppleWindowTabbingMode to "always" in the argument domain at launch (QuickMD only, not persisted): AppKit tabs each new window as it is ordered in. - Manual merge is a fallback for ordinary windows only; a window AppKit already tabbed is not resized. - WindowSizeMemory ignores full-screen windows, so a Split View half is no longer remembered as the size for new windows. Co-Authored-By: Claude Opus 5.5 --- QuickMD/QuickMD.xcodeproj/project.pbxproj | 6 + QuickMD/QuickMD/MarkdownView.swift | 8 +- QuickMD/QuickMD/QuickMDApp.swift | 9 +- QuickMD/QuickMD/WindowTabbing.swift | 66 ++++--- QuickMD/QuickMDTests/WindowTabbingTests.swift | 170 ++++++++++++++++++ 5 files changed, 229 insertions(+), 30 deletions(-) create mode 100644 QuickMD/QuickMDTests/WindowTabbingTests.swift diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index 2ed6313..42f17c0 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -140,6 +140,8 @@ A1B2C3D401000140 /* EditCloseGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000140 /* EditCloseGuard.swift */; }; A1B2C3D401000141 /* EditCloseGuard.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000140 /* EditCloseGuard.swift */; }; A1B2C3D401000142 /* EditCloseGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000141 /* EditCloseGuardTests.swift */; }; + A1B2C3D401000190 /* WindowTabbing.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000003F /* WindowTabbing.swift */; }; + A1B2C3D401000191 /* WindowTabbingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000190 /* WindowTabbingTests.swift */; }; A1B2C3D401000160 /* SourceEditSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000160 /* SourceEditSession.swift */; }; A1B2C3D401000161 /* SourceEditSession.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000160 /* SourceEditSession.swift */; }; A1B2C3D401000162 /* SourceEditSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000161 /* SourceEditSessionTests.swift */; }; @@ -180,6 +182,7 @@ A1B2C3D400000152 /* SourceEditorControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditorControllerTests.swift; sourceTree = ""; }; A1B2C3D400000140 /* EditCloseGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuard.swift; sourceTree = ""; }; A1B2C3D400000141 /* EditCloseGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuardTests.swift; sourceTree = ""; }; + A1B2C3D400000190 /* WindowTabbingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTabbingTests.swift; sourceTree = ""; }; A1B2C3D400000160 /* SourceEditSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSession.swift; sourceTree = ""; }; A1B2C3D400000161 /* SourceEditSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSessionTests.swift; sourceTree = ""; }; A1B2C3D400000180 /* SourceEditLanding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditLanding.swift; sourceTree = ""; }; @@ -318,6 +321,7 @@ A1B2C3D400000152 /* SourceEditorControllerTests.swift */, A1B2C3D400000171 /* SourceTintTests.swift */, A1B2C3D400000141 /* EditCloseGuardTests.swift */, + A1B2C3D400000190 /* WindowTabbingTests.swift */, A1B2C3D400000161 /* SourceEditSessionTests.swift */, A1B2C3D400000181 /* SourceEditLandingTests.swift */, ); @@ -694,6 +698,8 @@ A1B2C3D401000172 /* SourceTintTests.swift in Sources */, A1B2C3D401000141 /* EditCloseGuard.swift in Sources (Tests) */, A1B2C3D401000142 /* EditCloseGuardTests.swift in Sources */, + A1B2C3D401000190 /* WindowTabbing.swift in Sources (Tests) */, + A1B2C3D401000191 /* WindowTabbingTests.swift in Sources */, A1B2C3D401000161 /* SourceEditSession.swift in Sources (Tests) */, A1B2C3D401000162 /* SourceEditSessionTests.swift in Sources */, A1B2C3D401000181 /* SourceEditLanding.swift in Sources (Tests) */, diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index e9bfc52..e4d8c52 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -503,12 +503,8 @@ struct MarkdownView: View { } .background(theme.backgroundColor) .background(WindowConfigurator { window in - // Make every QuickMD document window prefer to join existing windows - // as tabs (rather than as standalone windows), regardless of the - // system-wide "Prefer tabs" preference. All windows share one - // tabbingIdentifier so AppKit groups them in a single tabbed window. - window.tabbingMode = .preferred - window.tabbingIdentifier = "pl.falami.studio.QuickMD.Document" + // Tabbing is set up app-wide (WindowTabbing.preferTabsAlways); the + // window keeps SwiftUI's shared tabbingIdentifier. // Remembered so the process-global key monitor below can ignore // key events addressed to OTHER windows (tabs are separate windows). hostWindow = window diff --git a/QuickMD/QuickMD/QuickMDApp.swift b/QuickMD/QuickMD/QuickMDApp.swift index 076917d..fdca9b4 100644 --- a/QuickMD/QuickMD/QuickMDApp.swift +++ b/QuickMD/QuickMD/QuickMDApp.swift @@ -17,11 +17,12 @@ struct QuickMDApp: App { init() { // Premium UX: every QuickMD document opens as a tab in an existing window - // (instead of stacking standalone windows). Overrides the system-wide - // "Prefer tabs when opening documents" preference for our app specifically. - // The actual tabbingMode + tabbingIdentifier is set per-window via - // WindowAccessor in MarkdownView. + // (instead of stacking standalone windows), also in full screen and + // Split View. Overrides the system-wide "Prefer tabs when opening + // documents" preference for our app only; AppKit does the tabbing as + // each window is ordered in. Must run before the first window exists. NSWindow.allowsAutomaticWindowTabbing = true + WindowTabbing.preferTabsAlways() } var body: some Scene { diff --git a/QuickMD/QuickMD/WindowTabbing.swift b/QuickMD/QuickMD/WindowTabbing.swift index e292e3d..9c8c4ff 100644 --- a/QuickMD/QuickMD/WindowTabbing.swift +++ b/QuickMD/QuickMD/WindowTabbing.swift @@ -4,13 +4,13 @@ import AppKit // MARK: - Window Configurator /// Bridges into AppKit to configure the host NSWindow once it's available. -/// Used to opt every document window into native macOS tabbing — we set the -/// shared `tabbingIdentifier` and, if another QuickMD doc window is already -/// visible, programmatically merge the new window as a tab. /// -/// We can't rely on AppKit's auto-tabbing (controlled by the system-wide -/// "Prefer tabs" pref) because we want consistent tab UX regardless of user -/// settings. `addTabbedWindow` is the explicit override. +/// Tabbing itself is AppKit's: `WindowTabbing.preferTabsAlways()` makes every +/// new document window join the frontmost one as a tab while AppKit orders it +/// in, in full screen and Split View too. SwiftUI already gives all +/// DocumentGroup windows one shared `tabbingIdentifier`, so it must be left as +/// is. This callback can't do the tabbing: on macOS 26 it fires only after the +/// window is on screen (and, in full screen, already in a Space of its own). struct WindowConfigurator: NSViewRepresentable { let configure: (NSWindow) -> Void @@ -18,7 +18,7 @@ struct WindowConfigurator: NSViewRepresentable { let view = TabAwareView() view.onWindowAttached = { window in configure(window) - let merged = mergeIntoExistingTabIfPossible(window: window) + let merged = WindowTabbing.joinExistingTabs(window, among: NSApp.windows) if !merged { // Standalone window (first of the session, or all others closed): // give it the last size the user chose instead of the hardcoded @@ -39,21 +39,43 @@ struct WindowConfigurator: NSViewRepresentable { } func updateNSView(_ nsView: NSView, context: Context) {} +} - @discardableResult - private func mergeIntoExistingTabIfPossible(window newWindow: NSWindow) -> Bool { - // Find another visible QuickMD document window already on screen and - // merge the new window into its tab group. - guard let identifier = newWindow.tabbingIdentifier as String?, - !identifier.isEmpty else { return false } +// MARK: - Window Tabbing + +enum WindowTabbing { + /// The defaults key behind `NSWindow.userTabbingPreference` (System + /// Settings ▸ Desktop & Dock ▸ "Prefer tabs when opening documents"). + static let preferenceKey = "AppleWindowTabbingMode" - let candidates = NSApp.windows.filter { other in + /// Every QuickMD document opens as a tab, whatever the system-wide setting + /// (default "In Full Screen" only). Set in the argument domain: it outranks + /// the global domain, is never written to disk, and leaves the user's + /// setting for other apps alone. Call before the first window exists. + static func preferTabsAlways(in defaults: UserDefaults = .standard) { + var arguments = defaults.volatileDomain(forName: UserDefaults.argumentDomain) + arguments[preferenceKey] = "always" + defaults.setVolatileDomain(arguments, forName: UserDefaults.argumentDomain) + } + + /// Called once the new window is on screen. Returns true when it ended up + /// as a tab (so it must keep the group's frame). + /// + /// Normally AppKit has already tabbed it. Merging by hand is a fallback for + /// ordinary windows only: in full screen the window already has its own + /// Space, and moving it into another window's tab group then leaves a + /// half-empty black full-screen Space without a menu bar. + @discardableResult + static func joinExistingTabs(_ newWindow: NSWindow, among windows: [NSWindow]) -> Bool { + if (newWindow.tabbedWindows?.count ?? 0) > 1 { return true } + guard let host = windows.first(where: { other in other !== newWindow && other.isVisible && other.tabbingIdentifier == newWindow.tabbingIdentifier - && other.tabGroup !== newWindow.tabGroup // not already grouped + }) else { return false } + if host.styleMask.contains(.fullScreen) || newWindow.styleMask.contains(.fullScreen) { + return false } - guard let host = candidates.first else { return false } host.addTabbedWindow(newWindow, ordered: .above) newWindow.makeKeyAndOrderFront(nil) return true @@ -90,7 +112,11 @@ enum WindowSizeMemory { return NSSize(width: w, height: h) } - private static func persist(_ size: NSSize) { + private static func persist(_ window: NSWindow) { + // A full-screen (or Split View) frame is the screen's size, not one the + // user chose; saving it would open later standalone windows screen-sized. + guard !window.styleMask.contains(.fullScreen) else { return } + let size = window.frame.size guard size.width >= minSize.width, size.height >= minSize.height else { return } let defaults = UserDefaults.standard defaults.set(Double(size.width), forKey: widthKey) @@ -101,7 +127,7 @@ enum WindowSizeMemory { /// frame, keeping the top-left corner anchored (so the title bar doesn't /// jump). No-op when nothing is saved or the size already matches. static func apply(to window: NSWindow) { - guard var target = savedSize() else { return } + guard !window.styleMask.contains(.fullScreen), var target = savedSize() else { return } if let screen = window.screen ?? NSScreen.main { target.width = min(target.width, screen.visibleFrame.width) target.height = min(target.height, screen.visibleFrame.height) @@ -123,7 +149,7 @@ enum WindowSizeMemory { forName: NSWindow.didEndLiveResizeNotification, object: window, queue: .main ) { note in guard let w = note.object as? NSWindow else { return } - persist(w.frame.size) + persist(w) } final class TokenBox { var token: NSObjectProtocol? } let closeBox = TokenBox() @@ -131,7 +157,7 @@ enum WindowSizeMemory { forName: NSWindow.willCloseNotification, object: window, queue: .main ) { note in if let w = note.object as? NSWindow { - persist(w.frame.size) + persist(w) } center.removeObserver(resizeToken) if let token = closeBox.token { center.removeObserver(token) } diff --git a/QuickMD/QuickMDTests/WindowTabbingTests.swift b/QuickMD/QuickMDTests/WindowTabbingTests.swift new file mode 100644 index 0000000..559a74e --- /dev/null +++ b/QuickMD/QuickMDTests/WindowTabbingTests.swift @@ -0,0 +1,170 @@ +import XCTest +import AppKit + +/// Full-screen / Split View tabbing (opening a file used to give the tab group +/// a full-screen Space of its own, half black, without a menu bar). Headless: +/// windows are never ordered on screen; full screen, visibility and tab +/// membership are faked by a subclass. That AppKit then tabs real windows in +/// place is E2E territory; here we pin the preference AppKit reads and the +/// decisions WindowConfigurator makes once a window is on screen. +final class WindowTabbingTests: XCTestCase { + + /// NSWindow whose full-screen state, visibility and tab group are set by + /// the test. + private final class FakeWindow: NSWindow { + var fakeFullScreen = false + var fakeVisible = false + var fakeTabs: [NSWindow]? + + convenience init(width: CGFloat = 600, height: CGFloat = 400) { + self.init(contentRect: NSRect(x: 0, y: 0, width: width, height: height), + styleMask: [.titled, .resizable], backing: .buffered, defer: true) + isReleasedWhenClosed = false + } + + override var styleMask: NSWindow.StyleMask { + get { fakeFullScreen ? super.styleMask.union(.fullScreen) : super.styleMask } + set { super.styleMask = newValue } + } + override var isVisible: Bool { fakeVisible } + override var tabbedWindows: [NSWindow]? { fakeTabs } + } + + private var savedArguments: [String: Any] = [:] + private var savedSize: (Any?, Any?) + + override func setUp() { + super.setUp() + let defaults = UserDefaults.standard + savedArguments = defaults.volatileDomain(forName: UserDefaults.argumentDomain) + savedSize = (defaults.object(forKey: "lastWindowFrameWidth"), + defaults.object(forKey: "lastWindowFrameHeight")) + } + + override func tearDown() { + let defaults = UserDefaults.standard + defaults.setVolatileDomain(savedArguments, forName: UserDefaults.argumentDomain) + defaults.set(savedSize.0, forKey: "lastWindowFrameWidth") + defaults.set(savedSize.1, forKey: "lastWindowFrameHeight") + super.tearDown() + } + + // MARK: - Preference + + /// The root cause: with the system default ("In Full Screen") a new window + /// arriving outside full screen, or one whose identifier no longer matched, + /// was never tabbed by AppKit. AppKit must report "always" for QuickMD. + func testPreferTabsAlwaysIsWhatAppKitReads() { + WindowTabbing.preferTabsAlways() + XCTAssertEqual(NSWindow.userTabbingPreference, .always) + } + + func testPreferTabsAlwaysKeepsOtherLaunchArguments() { + let defaults = UserDefaults.standard + var arguments = savedArguments + arguments["QuickMDTestArgument"] = "kept" + defaults.setVolatileDomain(arguments, forName: UserDefaults.argumentDomain) + + WindowTabbing.preferTabsAlways() + + let after = defaults.volatileDomain(forName: UserDefaults.argumentDomain) + XCTAssertEqual(after["QuickMDTestArgument"] as? String, "kept") + XCTAssertEqual(after[WindowTabbing.preferenceKey] as? String, "always") + } + + func testPreferTabsAlwaysIsNotPersisted() { + WindowTabbing.preferTabsAlways() + XCTAssertNil(UserDefaults.standard.persistentDomain( + forName: Bundle.main.bundleIdentifier ?? "")?[WindowTabbing.preferenceKey]) + } + + // MARK: - joinExistingTabs + + func testAlreadyTabbedByAppKitCountsAsMerged() { + let host = FakeWindow() + let new = FakeWindow() + new.fakeTabs = [host, new] + XCTAssertTrue(WindowTabbing.joinExistingTabs(new, among: [host, new])) + } + + func testFirstWindowIsStandalone() { + let new = FakeWindow() + XCTAssertFalse(WindowTabbing.joinExistingTabs(new, among: [new])) + } + + func testHiddenWindowsAreNotHosts() { + let hidden = FakeWindow() + let new = FakeWindow() + XCTAssertFalse(WindowTabbing.joinExistingTabs(new, among: [hidden, new])) + XCTAssertNil(hidden.tabbedWindows) + } + + func testOtherKindsOfWindowAreNotHosts() { + let settings = FakeWindow() + settings.fakeVisible = true + settings.tabbingIdentifier = "Settings" + let new = FakeWindow() + new.tabbingIdentifier = "Document" + XCTAssertFalse(WindowTabbing.joinExistingTabs(new, among: [settings, new])) + } + + /// The black-half bug: never move a window into a full-screen (or Split + /// View) tab group after it is already on screen. + func testNoLateMergeIntoFullScreenHost() { + let host = FakeWindow() + host.fakeVisible = true + host.fakeFullScreen = true + let new = FakeWindow() + XCTAssertFalse(WindowTabbing.joinExistingTabs(new, among: [host, new])) + } + + func testNoLateMergeOfWindowThatWentFullScreenOnItsOwn() { + let host = FakeWindow() + host.fakeVisible = true + let new = FakeWindow() + new.fakeFullScreen = true + XCTAssertFalse(WindowTabbing.joinExistingTabs(new, among: [host, new])) + } + + // MARK: - WindowSizeMemory in full screen + + func testApplyLeavesFullScreenWindowAlone() { + UserDefaults.standard.set(500.0, forKey: "lastWindowFrameWidth") + UserDefaults.standard.set(350.0, forKey: "lastWindowFrameHeight") + let window = FakeWindow(width: 900, height: 700) + window.fakeFullScreen = true + let before = window.frame.size + WindowSizeMemory.apply(to: window) + XCTAssertEqual(window.frame.size, before) + } + + func testApplyStillResizesOrdinaryWindow() { + UserDefaults.standard.set(500.0, forKey: "lastWindowFrameWidth") + UserDefaults.standard.set(350.0, forKey: "lastWindowFrameHeight") + let window = FakeWindow(width: 900, height: 700) + WindowSizeMemory.apply(to: window) + XCTAssertEqual(window.frame.size, NSSize(width: 500, height: 350)) + } + + /// A Split View half (1534 × 1920 on the reporter's Mac) was saved as the + /// "last size", so later standalone windows opened screen-sized. + func testFullScreenSizeIsNotRemembered() { + UserDefaults.standard.set(500.0, forKey: "lastWindowFrameWidth") + UserDefaults.standard.set(350.0, forKey: "lastWindowFrameHeight") + let window = FakeWindow(width: 1534, height: 1200) + window.fakeFullScreen = true + WindowSizeMemory.track(window) + NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: window) + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + XCTAssertEqual(UserDefaults.standard.double(forKey: "lastWindowFrameWidth"), 500) + XCTAssertEqual(UserDefaults.standard.double(forKey: "lastWindowFrameHeight"), 350) + } + + func testOrdinarySizeIsRemembered() { + let window = FakeWindow(width: 640, height: 480) + WindowSizeMemory.track(window) + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + XCTAssertEqual(UserDefaults.standard.double(forKey: "lastWindowFrameWidth"), window.frame.width) + XCTAssertEqual(UserDefaults.standard.double(forKey: "lastWindowFrameHeight"), window.frame.height) + } +}