diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 20a8543..01d23e8 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to QuickMD will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Reload** (⌘R, View ▸ Reload, or the ↻ button in the top-right cluster): re-reads the document from disk now and restarts the file watcher, for the times auto-reload does not notice a change (some network volumes and editors' save strategies). A toast says whether anything changed, or that the file could not be read; a file that is gone shows the "File no longer exists" banner, which clears by itself when the file comes back. Off while Source Edit shows, where the editor already follows the disk. Contributed by [@rinh](https://github.com/rinh) (#37). + +### Fixed +- Full screen and Split View: opening another file adds a tab in place. Before, the tab group moved to a full-screen Space of its own, leaving the Split View broken, half the screen black and no menu bar. A full-screen window size is also no longer remembered as the size for new windows. Contributed by [@onemannoplan](https://github.com/onemannoplan) (#36). +- The Support pill (GitHub build) no longer keeps a blue focus ring after a click when keyboard navigation is on in System Settings. + ## [1.12.0] - 2026-10-05 Source Edit: QuickMD stays a viewer first, but you can now fix the Markdown you are reading without leaving the window. Asked for by [@KillSwitchSIG](https://github.com/KillSwitchSIG) (#31) and [@vinaymve](https://github.com/vinaymve) (#34). 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/MarkdownExport.swift b/QuickMD/QuickMD/MarkdownExport.swift index c874d0c..f5bf0b6 100644 --- a/QuickMD/QuickMD/MarkdownExport.swift +++ b/QuickMD/QuickMD/MarkdownExport.swift @@ -28,6 +28,11 @@ struct FocusedOpenInExternalEditorKey: FocusedValueKey { typealias Value = () -> Void } +/// View ▸ Reload (⌘R) and the reload pill. +struct FocusedReloadDocumentKey: FocusedValueKey { + typealias Value = () -> Void +} + struct FocusedZoomActionKey: FocusedValueKey { typealias Value = (MarkdownZoom) -> Void } @@ -147,6 +152,10 @@ extension FocusedValues { get { self[FocusedOpenInExternalEditorKey.self] } set { self[FocusedOpenInExternalEditorKey.self] = newValue } } + var reloadDocumentAction: (() -> Void)? { + get { self[FocusedReloadDocumentKey.self] } + set { self[FocusedReloadDocumentKey.self] = newValue } + } var zoomAction: ((MarkdownZoom) -> Void)? { get { self[FocusedZoomActionKey.self] } set { self[FocusedZoomActionKey.self] = newValue } diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index e9bfc52..b30ef3a 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -404,9 +404,9 @@ struct MarkdownView: View { /// The top-right pill cluster. /// - /// Reading: zoom reset (while zoomed), Edit (Source Edit, ⌥⌘E), Open in - /// editor (⌘E), Copy source — gone in Reading Mode, whose shortcuts (⌘0, - /// ⌥⌘E, ⌘E, ⌘⇧C) all still work, so nothing is lost except the thing + /// Reading: zoom reset (while zoomed), Reload (⌘R), Edit (Source Edit, + /// ⌥⌘E), Copy source — gone in Reading Mode, whose shortcuts (⌘0, ⌘R, + /// ⌥⌘E, ⌘⇧C) all still work, so nothing is lost except the thing /// hovering over the text. /// /// Editing: ONLY Save and Done, also in Reading Mode — a mode you can only @@ -434,6 +434,9 @@ struct MarkdownView: View { .transition(.opacity) } if documentURL != nil { + ReloadDocumentButton(theme: theme) { + reloadManually() + } EditSourceButton(theme: theme) { toggleSourceEdit() } @@ -503,12 +506,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 @@ -526,6 +525,7 @@ struct MarkdownView: View { }) .focusedSceneValue(\.copyDocumentAction, { copyToClipboard(currentText) }) .focusedSceneValue(\.openInExternalEditorAction, { openInExternalEditor() }) + .focusedSceneValue(\.reloadDocumentAction, { reloadManually() }) .focusedSceneValue(\.toggleDocumentListAction, { guard !isReadingMode else { return } withAnimation(.easeInOut(duration: 0.2)) { isDocumentListVisible.toggle() } @@ -818,10 +818,14 @@ struct MarkdownView: View { fileWatcher = watcher } + /// What `reloadFromDisk` found — the ⌘R toast reports it. + private enum DiskReload { case changed, unchanged, unreadable, ownedBySession } + /// Re-reads the watched file and swaps the displayed text if it changed. /// Same decode + line-ending normalization as the initial document load. - private func reloadFromDisk() { - guard let url = documentURL else { return } + @discardableResult + private func reloadFromDisk() -> DiskReload { + guard let url = documentURL else { return .unreadable } if editSession.isActive { // The session reads the disk itself and keeps the rendered text // equal to it through `commitText` (S-D9) — or shows the banner @@ -831,17 +835,39 @@ struct MarkdownView: View { if fileMissing && FileManager.default.fileExists(atPath: url.path) { withAnimation(.easeInOut(duration: 0.2)) { fileMissing = false } } - return + return .ownedBySession } + // Only the read can fail: decoding falls back to Latin-1. guard let data = try? Data(contentsOf: url), - let decoded = MarkdownDocument.decode(data) else { return } + let decoded = MarkdownDocument.decode(data) else { return .unreadable } let text = MarkdownDocument.normalizeLineEndings(decoded) - if text != currentText { + let changed = text != currentText + if changed { currentText = text } if fileMissing { withAnimation(.easeInOut(duration: 0.2)) { fileMissing = false } } + return changed ? .changed : .unchanged + } + + /// ⌘R / the reload pill: re-read the file now, and restart the watcher in + /// case it lost track of the file (some volumes and save strategies never + /// deliver an event). Not while Source Edit shows — the session owns the + /// disk then. + private func reloadManually() { + guard !editSession.isActive, let url = documentURL else { return } + // Watch first, then read: a write landing in between is caught by the + // new watcher (at worst one more reload that finds nothing new). For a + // missing file the watcher shows the banner and polls for its return. + startWatching(url) + guard FileManager.default.fileExists(atPath: url.path) else { return } + switch reloadFromDisk() { + case .changed: showToast("Reloaded") + case .unchanged: showToast("Reloaded \u{00B7} no changes") + case .unreadable: showToast("Couldn't read the file") + case .ownedBySession: break + } } /// ⌘E — hand the document off to the user's configured editor, at the line diff --git a/QuickMD/QuickMD/QuickMDApp.swift b/QuickMD/QuickMD/QuickMDApp.swift index 076917d..34d50fb 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 { @@ -48,6 +49,8 @@ struct QuickMDApp: App { CopyMarkdownCommand() } CommandGroup(after: .toolbar) { + ReloadDocumentCommand() + Divider() ZoomCommands() ToggleToCCommand() ToggleDocumentListCommand() @@ -209,6 +212,22 @@ struct CopyMarkdownCommand: View { } } +/// View ▸ Reload (⌘R) — re-reads the frontmost document from disk, for the +/// times auto-reload did not notice a change (network volumes, some editors' +/// save strategies). Off while Source Edit shows: the session owns the disk. +struct ReloadDocumentCommand: View { + @FocusedValue(\.reloadDocumentAction) var reloadDocumentAction + @FocusedValue(\.sourceEditState) var sourceEditState + + var body: some View { + Button("Reload") { + reloadDocumentAction?() + } + .keyboardShortcut("r", modifiers: .command) + .disabled(reloadDocumentAction == nil || sourceEditState?.isEditing == true) + } +} + struct OpenInExternalEditorCommand: View { @FocusedValue(\.openInExternalEditorAction) var openInExternalEditorAction @FocusedValue(\.sourceEditState) var sourceEditState diff --git a/QuickMD/QuickMD/Views/ChromeButtons.swift b/QuickMD/QuickMD/Views/ChromeButtons.swift index 00f4351..1ddfd43 100644 --- a/QuickMD/QuickMD/Views/ChromeButtons.swift +++ b/QuickMD/QuickMD/Views/ChromeButtons.swift @@ -319,6 +319,22 @@ struct CopySourceButton: View { } } +// MARK: - Reload Button + +/// Top-right button: re-read the document from disk (⌘R). +struct ReloadDocumentButton: View { + let theme: MarkdownTheme + let action: () -> Void + + var body: some View { + ChromePill(theme: theme, title: "Reload", help: "Reload from disk (⌘R)", action: action) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11)) + } + .accessibilityIdentifier("reload-document") + } +} + // MARK: - Edit Source Button /// Top-right pencil button — fixes the source in place (Source Edit, ⌥⌘E). @@ -438,6 +454,10 @@ struct SupportButton: View { .environment(\.chromeExpansionSource, .fixed) } .menuStyle(.button) + // Like ChromePill: with keyboard navigation on (System Settings ▸ + // Keyboard), a clicked pop-up button keeps focus and AppKit draws a + // blue ring around the pill over the document. + .focusable(false) .opacity(hover.isExpanded ? 1.0 : 0.5) .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: hover.isExpanded) .modifier(ChromeHoverTracking(hover: $hover)) diff --git a/QuickMD/QuickMD/WindowTabbing.swift b/QuickMD/QuickMD/WindowTabbing.swift index e292e3d..5f2e2f6 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,8 @@ struct WindowConfigurator: NSViewRepresentable { let view = TabAwareView() view.onWindowAttached = { window in configure(window) - let merged = mergeIntoExistingTabIfPossible(window: window) + WindowTabbing.registerDocumentWindow(window) + let merged = WindowTabbing.joinExistingTabs(window, among: WindowTabbing.documentWindows) 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 +40,53 @@ struct WindowConfigurator: NSViewRepresentable { } func updateNSView(_ nsView: NSView, context: Context) {} +} + +// MARK: - Window Tabbing + +enum WindowTabbing { + /// The defaults key behind `NSWindow.userTabbingPreference` (System + /// Settings ▸ Desktop & Dock ▸ "Prefer tabs when opening documents"). + static let preferenceKey = "AppleWindowTabbingMode" + + /// 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) + } + + /// Every document window, registered as it attaches (WindowConfigurator + /// lives in MarkdownView only). The manual merge picks its host from these, + /// not by `tabbingIdentifier`: SwiftUI chooses that value, and the Settings + /// or Tip Jar window must never become a document's host. + private static let registeredDocumentWindows = NSHashTable.weakObjects() + + static var documentWindows: [NSWindow] { registeredDocumentWindows.allObjects } + + static func registerDocumentWindow(_ window: NSWindow) { + registeredDocumentWindows.add(window) + } + /// 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 - 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 } - - let candidates = NSApp.windows.filter { other in - other !== newWindow - && other.isVisible - && other.tabbingIdentifier == newWindow.tabbingIdentifier - && other.tabGroup !== newWindow.tabGroup // not already grouped + static func joinExistingTabs(_ newWindow: NSWindow, among documentWindows: [NSWindow]) -> Bool { + if (newWindow.tabbedWindows?.count ?? 0) > 1 { return true } + guard let host = documentWindows.first(where: { other in + other !== newWindow && other.isVisible + }) 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 +123,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 +138,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 +160,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 +168,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/ExternalEditorLineTests.swift b/QuickMD/QuickMDTests/ExternalEditorLineTests.swift index 8466b72..c74901b 100644 --- a/QuickMD/QuickMDTests/ExternalEditorLineTests.swift +++ b/QuickMD/QuickMDTests/ExternalEditorLineTests.swift @@ -224,7 +224,12 @@ final class ExternalEditorLineTests: XCTestCase { func testAppDisplayNameHasNoAppExtension() { let textEdit = URL(fileURLWithPath: "/System/Applications/TextEdit.app") let name = ExternalEditorManager.appDisplayName(at: textEdit) - XCTAssertEqual(name, "TextEdit") + // Localized like the toast it feeds ("文本编辑" on a Mac set to + // Chinese, #37), so the English name is pinned only under English. + if Locale.preferredLanguages.first?.hasPrefix("en") == true { + XCTAssertEqual(name, "TextEdit") + } + XCTAssertFalse(name.isEmpty) XCTAssertFalse(name.hasSuffix(".app")) let missing = URL(fileURLWithPath: "/nonexistent/Some Editor.app") XCTAssertEqual(ExternalEditorManager.appDisplayName(at: missing), "Some Editor") diff --git a/QuickMD/QuickMDTests/WindowTabbingTests.swift b/QuickMD/QuickMDTests/WindowTabbingTests.swift new file mode 100644 index 0000000..7ca697a --- /dev/null +++ b/QuickMD/QuickMDTests/WindowTabbingTests.swift @@ -0,0 +1,191 @@ +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; tab merges and ordering are recorded, never performed. + private final class FakeWindow: NSWindow { + var fakeFullScreen = false + var fakeVisible = false + var fakeTabs: [NSWindow]? + private(set) var addedTabs: [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 } + override func addTabbedWindow(_ window: NSWindow, ordered: NSWindow.OrderingMode) { + addedTabs.append(window) + } + override func makeKeyAndOrderFront(_ sender: Any?) {} + } + + 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])) + XCTAssertTrue(hidden.addedTabs.isEmpty) + } + + /// Hosts come from the registered document windows: the Settings or Tip + /// Jar window is never registered, whatever identifier SwiftUI gives it. + func testOnlyRegisteredWindowsAreDocumentWindows() { + let document = FakeWindow() + let settings = FakeWindow() + WindowTabbing.registerDocumentWindow(document) + XCTAssertTrue(WindowTabbing.documentWindows.contains { $0 === document }) + XCTAssertFalse(WindowTabbing.documentWindows.contains { $0 === settings }) + } + + /// The fallback does not depend on SwiftUI sharing one identifier across + /// document windows (only seen on macOS 26): outside full screen a visible + /// document window takes the new one as a tab, as before #36. + func testVisibleDocumentHostMergesWhateverItsIdentifier() { + let host = FakeWindow() + host.fakeVisible = true + host.tabbingIdentifier = "SwiftUI-1" + let new = FakeWindow() + new.tabbingIdentifier = "SwiftUI-2" + XCTAssertTrue(WindowTabbing.joinExistingTabs(new, among: [host, new])) + XCTAssertTrue(host.addedTabs.count == 1 && host.addedTabs.first === 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])) + XCTAssertTrue(host.addedTabs.isEmpty) + } + + func testNoLateMergeOfWindowThatWentFullScreenOnItsOwn() { + let host = FakeWindow() + host.fakeVisible = true + let new = FakeWindow() + new.fakeFullScreen = true + XCTAssertFalse(WindowTabbing.joinExistingTabs(new, among: [host, new])) + XCTAssertTrue(host.addedTabs.isEmpty) + } + + // 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) + } +} diff --git a/README.md b/README.md index 01b6229..1116d95 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Perfect for developers, writers, students, and anyone who works with Markdown da ### Works with Your Editor - **Auto-reload** — the document refreshes the moment another app saves it. Turn on auto-save in VS Code, Cursor or Zed, or let an AI agent write the file, and QuickMD is a live preview +- **Reload (`⌘R`)** — re-read the file on demand, for the rare change auto-reload misses (network volumes, unusual save strategies) - **Open in External Editor (`⌘E`)** — for longer writing sessions: hands the file to your editor (auto-detected, configurable in Settings). VS Code, BBEdit, TextMate and Nova open at the line you are reading ### Complete Markdown Support