Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions QuickMD/QuickMD.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -180,6 +182,7 @@
A1B2C3D400000152 /* SourceEditorControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditorControllerTests.swift; sourceTree = "<group>"; };
A1B2C3D400000140 /* EditCloseGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuard.swift; sourceTree = "<group>"; };
A1B2C3D400000141 /* EditCloseGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCloseGuardTests.swift; sourceTree = "<group>"; };
A1B2C3D400000190 /* WindowTabbingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTabbingTests.swift; sourceTree = "<group>"; };
A1B2C3D400000160 /* SourceEditSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSession.swift; sourceTree = "<group>"; };
A1B2C3D400000161 /* SourceEditSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditSessionTests.swift; sourceTree = "<group>"; };
A1B2C3D400000180 /* SourceEditLanding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceEditLanding.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -318,6 +321,7 @@
A1B2C3D400000152 /* SourceEditorControllerTests.swift */,
A1B2C3D400000171 /* SourceTintTests.swift */,
A1B2C3D400000141 /* EditCloseGuardTests.swift */,
A1B2C3D400000190 /* WindowTabbingTests.swift */,
A1B2C3D400000161 /* SourceEditSessionTests.swift */,
A1B2C3D400000181 /* SourceEditLandingTests.swift */,
);
Expand Down Expand Up @@ -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) */,
Expand Down
8 changes: 2 additions & 6 deletions QuickMD/QuickMD/MarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions QuickMD/QuickMD/QuickMDApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
66 changes: 46 additions & 20 deletions QuickMD/QuickMD/WindowTabbing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,21 @@ 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

func makeNSView(context: Context) -> NSView {
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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -123,15 +149,15 @@ 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()
closeBox.token = center.addObserver(
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) }
Expand Down
170 changes: 170 additions & 0 deletions QuickMD/QuickMDTests/WindowTabbingTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading