From 15544c70fb9171e561b931620642993704b51312 Mon Sep 17 00:00:00 2001 From: onemannoplan Date: Sun, 18 Oct 2026 16:00:35 -0500 Subject: [PATCH 1/6] 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) + } +} From 760c22fd4b25b63175dd31ddd8b1dc6396b293cf Mon Sep 17 00:00:00 2001 From: rinh Date: Thu, 22 Oct 2026 12:02:19 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(reload):=20Reload=20the=20document=20f?= =?UTF-8?q?rom=20disk=20on=20demand=20(=E2=8C=98R)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-reload relies on file-system events, which some network volumes and editors' save strategies never deliver; the only way back to the current text was closing and reopening the document. View ▸ Reload (⌘R) and a ↻ pill in the top-right cluster re-read the file now and restart the watcher, so a watcher that lost track of the file recovers too. A toast says whether anything changed ("Reloaded" / "Reloaded · no changes"); a missing file shows the existing "File no longer exists" banner. Disabled while Source Edit shows: the session already follows the disk and owns the conflict banner, so a second path to the same file would race it. Routed through @FocusedValue like every other per-document command, so ⌘R acts on the frontmost tab only. Co-Authored-By: Claude Opus 5.5 --- QuickMD/CHANGELOG.md | 5 +++++ QuickMD/QuickMD/MarkdownExport.swift | 9 +++++++++ QuickMD/QuickMD/MarkdownView.swift | 20 ++++++++++++++++++++ QuickMD/QuickMD/QuickMDApp.swift | 18 ++++++++++++++++++ QuickMD/QuickMD/Views/ChromeButtons.swift | 16 ++++++++++++++++ README.md | 1 + 6 files changed, 69 insertions(+) diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 20a8543..6cc3030 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -5,6 +5,11 @@ 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; a file that is gone shows the "File no longer exists" banner. Off while Source Edit shows, where the editor already follows the disk. + ## [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/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..e5b2ff8 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -434,6 +434,9 @@ struct MarkdownView: View { .transition(.opacity) } if documentURL != nil { + ReloadDocumentButton(theme: theme) { + reloadManually() + } EditSourceButton(theme: theme) { toggleSourceEdit() } @@ -526,6 +529,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() } @@ -844,6 +848,22 @@ struct MarkdownView: View { } } + /// ⌘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 } + guard FileManager.default.fileExists(atPath: url.path) else { + withAnimation(.easeInOut(duration: 0.2)) { fileMissing = true } + return + } + let before = currentText + reloadFromDisk() + startWatching(url) + showToast(currentText == before ? "Reloaded \u{00B7} no changes" : "Reloaded") + } + /// ⌘E — hand the document off to the user's configured editor, at the line /// the reader is at when that editor documents a line link (v1.11 E-D1). private func openInExternalEditor() { diff --git a/QuickMD/QuickMD/QuickMDApp.swift b/QuickMD/QuickMD/QuickMDApp.swift index 076917d..5a47789 100644 --- a/QuickMD/QuickMD/QuickMDApp.swift +++ b/QuickMD/QuickMD/QuickMDApp.swift @@ -48,6 +48,8 @@ struct QuickMDApp: App { CopyMarkdownCommand() } CommandGroup(after: .toolbar) { + ReloadDocumentCommand() + Divider() ZoomCommands() ToggleToCCommand() ToggleDocumentListCommand() @@ -209,6 +211,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..6938728 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). 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 From 77c98cf8e6112dd829ac012a5ce033cbc76bb0e5 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 12:47:03 +0200 Subject: [PATCH 3/6] fix(reload): say when the file cannot be read; re-arm the watcher first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #37. - A failed read (network volume error, chmod 000, sandbox denial) toasted "Reloaded · no changes": decoding never fails (Latin-1 fallback), so `reloadFromDisk` returned silently and the text compared equal. It now reports what it found and ⌘R says "Couldn't read the file". - ⌘R on a missing file set the banner but skipped `startWatching`, so the old watcher stayed armed on a dead descriptor with no missing-file poll. The watcher is restarted in every branch; for a missing file it shows the banner itself and polls for the file's return. - Watch first, then read: a write landing between the two is caught by the new watcher instead of being dropped with the old one's debounce. - Pill cluster comment lists Reload (and no longer the removed "Open in editor" pill). Co-Authored-By: Claude Opus 5.5 --- QuickMD/CHANGELOG.md | 2 +- QuickMD/QuickMD/MarkdownView.swift | 40 +++++++++++++++++++----------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 6cc3030..e9300ac 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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; a file that is gone shows the "File no longer exists" banner. Off while Source Edit shows, where the editor already follows the disk. +- **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. ## [1.12.0] - 2026-10-05 diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index e5b2ff8..3098b39 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 @@ -822,10 +822,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 @@ -835,17 +839,20 @@ 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 @@ -854,14 +861,17 @@ struct MarkdownView: View { /// disk then. private func reloadManually() { guard !editSession.isActive, let url = documentURL else { return } - guard FileManager.default.fileExists(atPath: url.path) else { - withAnimation(.easeInOut(duration: 0.2)) { fileMissing = true } - return - } - let before = currentText - reloadFromDisk() + // 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) - showToast(currentText == before ? "Reloaded \u{00B7} no changes" : "Reloaded") + 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 From 7c325f5ac5c30eee49247c6e9d1e94461387299a Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 12:47:33 +0200 Subject: [PATCH 4/6] test(editor): pin TextEdit's English name only under English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `appDisplayName` returns the localized name, as the toast should; on a Mac set to Chinese it is "文本编辑" and the test failed (reported in #37). The ".app"-suffix and fallback checks still run in every language. Co-Authored-By: Claude Opus 5.5 --- QuickMD/QuickMDTests/ExternalEditorLineTests.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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") From 26fa99b619beaa51bba9de6a698d6911182dc8a5 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 12:52:51 +0200 Subject: [PATCH 5/6] fix(tabs): pick the manual-merge host from document windows, not by identifier Follow-up to #36. - The fallback merge matched hosts by `tabbingIdentifier`, which is now SwiftUI's own value. Shared across document windows on macOS 26, but not documented, and unverified on 13-15: if it differed per window, a second file would open standalone outside full screen, a regression from 1.12. Hosts now come from the windows WindowConfigurator registered (it lives in MarkdownView only), so the fallback works whatever SwiftUI picks, and the Settings or Tip Jar window can never become a document's host. - Tests: FakeWindow records `addTabbedWindow` instead of performing it (the hidden-host assertion read an override that was never set); a test that a visible document host merges with a different identifier (fails on the identifier match); the full-screen tests also assert no merge happened. - CHANGELOG: Fixed entry for #36, credit for #37. Co-Authored-By: Claude Opus 5.5 --- QuickMD/CHANGELOG.md | 5 ++- QuickMD/QuickMD/WindowTabbing.swift | 23 ++++++++---- QuickMD/QuickMDTests/WindowTabbingTests.swift | 35 +++++++++++++++---- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index e9300ac..ec54365 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -8,7 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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. +- **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). ## [1.12.0] - 2026-10-05 diff --git a/QuickMD/QuickMD/WindowTabbing.swift b/QuickMD/QuickMD/WindowTabbing.swift index 9c8c4ff..5f2e2f6 100644 --- a/QuickMD/QuickMD/WindowTabbing.swift +++ b/QuickMD/QuickMD/WindowTabbing.swift @@ -18,7 +18,8 @@ struct WindowConfigurator: NSViewRepresentable { let view = TabAwareView() view.onWindowAttached = { window in configure(window) - let merged = WindowTabbing.joinExistingTabs(window, among: NSApp.windows) + 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 @@ -58,6 +59,18 @@ enum WindowTabbing { 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). /// @@ -66,12 +79,10 @@ enum WindowTabbing { /// 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 { + static func joinExistingTabs(_ newWindow: NSWindow, among documentWindows: [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 + 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 diff --git a/QuickMD/QuickMDTests/WindowTabbingTests.swift b/QuickMD/QuickMDTests/WindowTabbingTests.swift index 559a74e..7ca697a 100644 --- a/QuickMD/QuickMDTests/WindowTabbingTests.swift +++ b/QuickMD/QuickMDTests/WindowTabbingTests.swift @@ -10,11 +10,12 @@ import AppKit final class WindowTabbingTests: XCTestCase { /// NSWindow whose full-screen state, visibility and tab group are set by - /// the test. + /// 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), @@ -28,6 +29,10 @@ final class WindowTabbingTests: XCTestCase { } 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] = [:] @@ -96,16 +101,30 @@ final class WindowTabbingTests: XCTestCase { let hidden = FakeWindow() let new = FakeWindow() XCTAssertFalse(WindowTabbing.joinExistingTabs(new, among: [hidden, new])) - XCTAssertNil(hidden.tabbedWindows) + XCTAssertTrue(hidden.addedTabs.isEmpty) } - func testOtherKindsOfWindowAreNotHosts() { + /// 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() - settings.fakeVisible = true - settings.tabbingIdentifier = "Settings" + 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 = "Document" - XCTAssertFalse(WindowTabbing.joinExistingTabs(new, among: [settings, new])) + 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 @@ -116,6 +135,7 @@ final class WindowTabbingTests: XCTestCase { host.fakeFullScreen = true let new = FakeWindow() XCTAssertFalse(WindowTabbing.joinExistingTabs(new, among: [host, new])) + XCTAssertTrue(host.addedTabs.isEmpty) } func testNoLateMergeOfWindowThatWentFullScreenOnItsOwn() { @@ -124,6 +144,7 @@ final class WindowTabbingTests: XCTestCase { let new = FakeWindow() new.fakeFullScreen = true XCTAssertFalse(WindowTabbing.joinExistingTabs(new, among: [host, new])) + XCTAssertTrue(host.addedTabs.isEmpty) } // MARK: - WindowSizeMemory in full screen From afac0c81ddff60eab76c8547a288c3bf703677c7 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 22 Oct 2026 13:02:49 +0200 Subject: [PATCH 6/6] fix(chrome): Support pill never takes keyboard focus (no blue ring) With keyboard navigation on (System Settings > Keyboard), the Support `Menu` (an NSPopUpButton underneath) kept first responder after a click and AppKit drew a focus ring around it over the document. The other pills already opt out through ChromePill's `.focusable(false)`; the Menu did not. Verified on a .proto build with AppleKeyboardUIMode = 2: before, AX could focus the pill (AXFocused settable, became the focused element); after, it refuses focus, a click still opens the menu, and after Esc the focus is back on the document table with no ring. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BhzVb6aPQs2FF1bHdkcYys --- QuickMD/CHANGELOG.md | 1 + QuickMD/QuickMD/Views/ChromeButtons.swift | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index ec54365..01d23e8 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/QuickMD/QuickMD/Views/ChromeButtons.swift b/QuickMD/QuickMD/Views/ChromeButtons.swift index 6938728..1ddfd43 100644 --- a/QuickMD/QuickMD/Views/ChromeButtons.swift +++ b/QuickMD/QuickMD/Views/ChromeButtons.swift @@ -454,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))