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) + } +}