diff --git a/MacPacker/AppDelegate.swift b/MacPacker/AppDelegate.swift index 7e177826..c66b4faa 100644 --- a/MacPacker/AppDelegate.swift +++ b/MacPacker/AppDelegate.swift @@ -443,6 +443,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { self.archiveWindowManager?.openNewArchiveWindow() } + func openNewTab() { + self.archiveWindowManager?.openNewTab() + } + func openCreateArchiveWindow() { self.archiveWindowManager?.openCreateArchiveWindow() } diff --git a/MacPacker/Features/ArchiveContentViewer/ArchiveContentToolbarView.swift b/MacPacker/Features/ArchiveContentViewer/ArchiveContentToolbarView.swift index d3cc4eb9..7d25bbb7 100644 --- a/MacPacker/Features/ArchiveContentViewer/ArchiveContentToolbarView.swift +++ b/MacPacker/Features/ArchiveContentViewer/ArchiveContentToolbarView.swift @@ -19,6 +19,7 @@ extension NSImage { struct ArchiveContentToolbarView: ToolbarContent { @EnvironmentObject private var appState: AppState @Environment(\.openQuickCompressWindow) private var openQuickCompressWindow + @Environment(\.openNewTab) private var openNewTab @Environment(\.openURL) private var openURL @Environment(\.openSettings) private var openSettings @State private var isExportingItem: Bool = false @@ -157,6 +158,17 @@ struct ArchiveContentToolbarView: ToolbarContent { } Spacer() + + Button { + openNewTab() + } label: { + Label { + Text(.commonNewTab) + } icon: { + Image(systemName: "plus.square.on.square") + } + } + .help(.archiveNewTabHint) Menu { Button { diff --git a/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift b/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift index e2952a20..27faec79 100644 --- a/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift +++ b/MacPacker/Features/ArchiveWindow/ArchiveWindow.swift @@ -20,8 +20,15 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { var willCloseHandler: (() -> Void)? var didBecomeMain: (() -> Void)? + /// Opens the home screen in a new tab of this window. + private let openNewTab: @MainActor @Sendable () -> Void private var cancellables = Set() + + /// Whether this window shows its tab bar (see `followTabBar`). + let tabBarPresence = TabBarPresence() + private weak var followedTabGroup: NSWindowTabGroup? + private var tabBarObservation: NSKeyValueObservation? init( archiveState: ArchiveState, @@ -30,9 +37,11 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { quickCompressOptions: ArchiveSaveOptions, openQuickCompressWindow: @escaping @MainActor () -> Void, openArchiveInNewWindow: @escaping @MainActor (URL) -> Void, + openNewTab: @escaping @MainActor @Sendable () -> Void, cascadeFrom: NSWindow? = nil ) { self.archiveState = archiveState + self.openNewTab = openNewTab let window = ArchiveWindow() window.isRestorable = false @@ -56,6 +65,11 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { window.delegate = self + // Archives open as tabs of the front window when Settings say so, and + // as macOS's own preference has it otherwise. + window.tabbingIdentifier = "MacPackerArchiveWindow" + window.tabbingMode = UserDefaults.standard.bool(forKey: Keys.openArchivesInTabs) ? .preferred : .automatic + window.toolbarStyle = .unified // show the content view @@ -65,9 +79,11 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { .environmentObject(dropCompressor) .environmentObject(quickCompressOptions) .environment(\.openArchiveInNewWindow, openArchiveInNewWindow) + .environment(\.openNewTab, openNewTab) .environment(\.openQuickCompressWindow, openQuickCompressWindow) - window.contentView = NSHostingView(rootView: contentView) + window.contentView = NSHostingView(rootView: TabBarAware(presence: tabBarPresence, content: contentView)) + followTabBar() // native unsaved-changes affordance: the close button shows a dot while // there are pending edits (mirrors NSDocument's isDocumentEdited) @@ -76,6 +92,16 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { .removeDuplicates() .sink { [weak window] edited in window?.isDocumentEdited = edited } .store(in: &cancellables) + + // As a tab, the home screen is a "New Tab"; with an archive, the tab + // reads as the window's title does. The title of a window on its own + // is not touched. + archiveState.$hasArchive + .removeDuplicates() + .sink { [weak window] hasArchive in + window?.tab.title = hasArchive ? nil : String(localized: .commonNewTab) + } + .store(in: &cancellables) } required init?(coder: NSCoder) { @@ -152,6 +178,55 @@ class ArchiveWindowController: NSWindowController, NSWindowDelegate { archiveState.clean() willCloseHandler?() } + + func windowDidBecomeKey(_ notification: Notification) { followTabBar() } + + /// Keeps `tabBarPresence` in step with the tab bar of this window's tab + /// group. A window changes group when a tab is dragged out or windows are + /// merged, so the group is looked up again whenever the window becomes key. + private func followTabBar() { + guard let group = window?.tabGroup, group !== followedTabGroup else { return } + followedTabGroup = group + tabBarObservation = group.observe(\.isTabBarVisible, options: [.initial, .new]) { [weak self] group, _ in + let shown = group.isTabBarVisible + MainActor.assumeIsolated { self?.tabBarPresence.show(shown) } + } + } +} + +/// Whether a window shows its tab bar, for the home screen (see +/// `HomeView.clearanceBelowBars`). It shows at once, and hides only once the +/// tab bar's closing animation is over, so the page doesn't run under the bars +/// while the last separators are still moving. +@MainActor +final class TabBarPresence: ObservableObject { + @Published private(set) var isShown = false + private var pendingHide: Task? + + func show(_ shown: Bool) { + pendingHide?.cancel() + pendingHide = nil + if shown { + if !isShown { isShown = true } + return + } + guard isShown else { return } + pendingHide = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(600)) + guard !Task.isCancelled else { return } + self?.isShown = false + } + } +} + +/// Hands the window's tab bar state down to its content. +private struct TabBarAware: View { + @ObservedObject var presence: TabBarPresence + let content: Content + + var body: some View { + content.environment(\.windowShowsTabBar, presence.isShown) + } } class ArchiveWindow: NSWindow { @@ -211,6 +286,19 @@ extension EnvironmentValues { } } +/// Opens MacPacker's home screen in a new tab of the window: the toolbar's +/// New Tab button. +private struct OpenNewTabKey: EnvironmentKey { + static let defaultValue: @MainActor @Sendable () -> Void = { } +} + +extension EnvironmentValues { + var openNewTab: @MainActor @Sendable () -> Void { + get { self[OpenNewTabKey.self] } + set { self[OpenNewTabKey.self] = newValue } + } +} + /// Opens the floating quick-compress window. An action, not state, so it travels /// as a closure rather than as an object a view has to hold. private struct OpenQuickCompressWindowKey: EnvironmentKey { diff --git a/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift b/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift index af17d266..a603c160 100644 --- a/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift +++ b/MacPacker/Features/ArchiveWindow/ArchiveWindowManager.swift @@ -46,11 +46,16 @@ class ArchiveWindowManager { } /// Creates a new archive window and loads the archive from the given url if available - /// - Parameter url: url of the archive + /// - Parameters: + /// - url: url of the archive + /// - host: When given, the window opens as a tab of that one, whatever + /// Settings say about tabs: the user asked for a tab. + /// - asWindow: The user asked for a window (File ▸ New Window): it opens + /// as macOS's own preference has it, whatever Settings say about tabs. /// - Returns: the window's `ArchiveState`, so callers that need to drive it /// (e.g. the debug screenshot launcher) can navigate and select afterwards. @discardableResult - fileprivate func createAndShowArchiveWindow(_ url: URL?) -> ArchiveState { + fileprivate func createAndShowArchiveWindow(_ url: URL?, asTabOf host: NSWindow? = nil, asWindow: Bool = false) -> ArchiveState { log.notice("Creating window", context: ["url": url?.lastPathComponent ?? "(empty)"]) if !hasOpenedWindow { hasOpenedWindow = true @@ -86,16 +91,38 @@ class ArchiveWindowManager { openArchiveInNewWindow: { [weak self] url in self?.openDroppedInNewWindow(url) }, + openNewTab: { [weak self] in + self?.openNewTab() + }, cascadeFrom: modelWindow ) windowControllers.append(archiveWindowController) archiveWindowController.willCloseHandler = { [weak self] in self?.windowControllers.removeAll { $0 === archiveWindowController } } + if let host, let window = archiveWindowController.window, host !== window { + // both know the tab bar is coming before it slides in + (host.windowController as? ArchiveWindowController)?.tabBarPresence.show(true) + archiveWindowController.tabBarPresence.show(true) + host.addTabbedWindow(window, ordered: .above) + } + // A window the user asked for goes by macOS's preference alone. Archives + // opened later can still become its tabs: they join it by its tabbing + // identifier, whatever its own mode. + if asWindow { + archiveWindowController.window?.tabbingMode = .automatic + } archiveWindowController.showWindow(nil) return archiveState } + /// An empty window to open an archive in: the one in use, when it is + /// empty — the New Tab just opened, say — else the first one. + private var emptyWindowController: ArchiveWindowController? { + let empty = windowControllers.filter { $0.archiveState.hasArchive == false } + return empty.first { $0.window?.isMainWindow == true } ?? empty.first + } + /// The window a new one models itself on. Falls back to the newest when no /// window is main (Finder's "Add to Archive…" keeps focus). Nil when none are /// open — the new window then uses the remembered frame. @@ -126,7 +153,13 @@ class ArchiveWindowManager { /// Opens a new empty window, regardless of whether there is any other window open right now func openNewArchiveWindow() { - createAndShowArchiveWindow(nil) + createAndShowArchiveWindow(nil, asWindow: true) + } + + /// Opens the home screen in a new tab of the window in use — or in a + /// window of its own when none is open. + func openNewTab() { + createAndShowArchiveWindow(nil, asTabOf: modelWindow) } /// A file dropped on the "open in a new window" zone of a window that is @@ -178,7 +211,7 @@ class ArchiveWindowManager { private func showWindow(for url: URL) -> ArchiveState { if let wc = focusWindow(holding: url) { return wc.archiveState - } else if let ewc = windowControllers.first(where: { $0.archiveState.hasArchive == false }) { + } else if let ewc = emptyWindowController { log.notice("Reusing empty window to open \(url.lastPathComponent)") ewc.archiveState.open(url: url) ewc.showWindow(nil) diff --git a/MacPacker/Features/Home/HomeView.swift b/MacPacker/Features/Home/HomeView.swift index eb7a2c47..0b679aec 100644 --- a/MacPacker/Features/Home/HomeView.swift +++ b/MacPacker/Features/Home/HomeView.swift @@ -31,6 +31,11 @@ enum HomeLayout { static let sectionHeaderHeight: CGFloat = 22 } +extension EnvironmentValues { + /// Whether the window shows its tab bar (see `TabBarPresence`). + @Entry var windowShowsTabBar = false +} + struct HomeView: View { @EnvironmentObject private var state: ArchiveState @EnvironmentObject private var compressor: DropCompressor @@ -56,6 +61,20 @@ struct HomeView: View { private let maxRecents = 4 + @Environment(\.windowShowsTabBar) private var windowShowsTabBar + + /// While the window shows its tab bar, on macOS 26 and later, the page + /// stops this far below the toolbar and tab bar. Running under them, it + /// brings out the title bar's background, and over that background the tab + /// bar leaves stale separators behind each time a tab is added or closed + /// (seen on macOS 27). Without a tab bar it runs under them as before, and + /// the bar lights up when the pointer enters it. The page's own top padding + /// gives the point back, so nothing moves. + private var clearanceBelowBars: CGFloat { + if #available(macOS 26, *), windowShowsTabBar { return 1 } + return 0 + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 36) { @@ -78,10 +97,11 @@ struct HomeView: View { // room to breathe, but the whole page still has to clear a 500pt window // (less toolbar, tab bar and status bar) without scrolling .padding(.horizontal, 32) - .padding(.top, 24) + .padding(.top, 24 - clearanceBelowBars) .padding(.bottom, 16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + .padding(.top, clearanceBelowBars) .onAppear { recents = Array(RecentArchives.urls.prefix(maxRecents)) #if DEBUG diff --git a/MacPacker/Features/Settings/GeneralSettingsView.swift b/MacPacker/Features/Settings/GeneralSettingsView.swift index f5c96a9e..93839eba 100644 --- a/MacPacker/Features/Settings/GeneralSettingsView.swift +++ b/MacPacker/Features/Settings/GeneralSettingsView.swift @@ -17,6 +17,7 @@ struct GeneralSettingsView: View { @AppStorage(Keys.showColumnPosixPermissions) var showPermissions: Bool = false @AppStorage(Keys.showParentRow) var showParentRow: Bool = false @AppStorage(Keys.quitOnLastWindowClosed) var quitOnLastWindowClosed: Bool = false + @AppStorage(Keys.openArchivesInTabs) var openArchivesInTabs: Bool = true @AppStorage(Keys.showMenuBarItem) var showMenuBarItem: Bool = false @AppStorage(Keys.rememberRecentArchives) var rememberRecentArchives: Bool = true @AppStorage(Keys.smartExtraction, store: .macPackerShared) var smartExtraction: Bool = true @@ -85,6 +86,18 @@ struct GeneralSettingsView: View { .frame(width: 240, alignment: .leading) } + HStack(alignment: .top) { + Text(.settingsOpenArchivesInTabs) + .frame(width: 200, alignment: .trailing) + + HStack { + Toggle(isOn: $openArchivesInTabs) {} + } + .padding(.leading, 8) + .frame(width: 240, alignment: .leading) + } + .help(Text(.settingsOpenArchivesInTabsHint)) + HStack(alignment: .top) { Text(.settingsShowInMenuBar) .frame(width: 200, alignment: .trailing) diff --git a/MacPacker/Localizable.xcstrings b/MacPacker/Localizable.xcstrings index 46868947..40e05c38 100644 --- a/MacPacker/Localizable.xcstrings +++ b/MacPacker/Localizable.xcstrings @@ -2771,6 +2771,18 @@ } } }, + "archiveNewTabHint": { + "comment": "Tooltip of the toolbar's New Tab button.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open the home screen in a new tab" + } + } + } + }, "archivePasswordMaxLengthHint": { "comment": "Save options: the zip password is too long; the number is the limit", "extractionState": "manual", @@ -11390,6 +11402,18 @@ } } }, + "commonNewTab": { + "comment": "Toolbar button and File menu item: opens MacPacker's home screen in a new tab of the window.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Tab" + } + } + } + }, "commonNewWindow": { "comment": "A label for a button that opens a new window of the app. The placeholder will add the name MacPacker to it.", "extractionState": "manual", @@ -23706,6 +23730,30 @@ } } }, + "settingsOpenArchivesInTabs": { + "comment": "General setting: archives open as tabs of the front window.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open archives in tabs" + } + } + } + }, + "settingsOpenArchivesInTabsHint": { + "comment": "Tooltip of the General setting that opens archives as tabs of the front window.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archives open as tabs of the front window. Off, each opens in a window of its own, unless macOS is set to prefer tabs." + } + } + } + }, "settingsOpenCacheDirectory": { "comment": "Allows the user to open the application support folder that holds the cache for temporarly extracted archive files", "extractionState": "manual", diff --git a/MacPacker/MacPackerApp.swift b/MacPacker/MacPackerApp.swift index 186a278d..33f5a5a2 100644 --- a/MacPacker/MacPackerApp.swift +++ b/MacPacker/MacPackerApp.swift @@ -76,6 +76,17 @@ struct MacPackerApp: App { } .keyboardShortcut("n", modifiers: [.command, .shift]) + Button { + appDelegate.openNewTab() + } label: { + Label { + Text(.commonNewTab) + } icon: { + Image(systemName: "plus.square.on.square") + } + } + .keyboardShortcut("t", modifiers: [.command]) + Button { appDelegate.showDropWindow() } label: { diff --git a/Modules/Sources/Core/AppStorageKeys.swift b/Modules/Sources/Core/AppStorageKeys.swift index de564b58..f06578d9 100644 --- a/Modules/Sources/Core/AppStorageKeys.swift +++ b/Modules/Sources/Core/AppStorageKeys.swift @@ -11,6 +11,8 @@ public enum Keys { // general settings public static let settingBreadcrumbPosition = "settingBreadcrumbPosition" public static let quitOnLastWindowClosed = "quitOnLastWindowClosed" + /// Whether archives open as tabs of the front window. On by default. + public static let openArchivesInTabs = "openArchivesInTabs" /// Whether opened archives are remembered for the start page's Recent list /// (and the Dock's Recent Documents menu). On by default; turning it off also /// empties what was already collected — a history you can no longer see is one @@ -79,6 +81,7 @@ public enum Keys { defaultOrderColumnAscending: true, dropWindowFloats: true, rememberRecentArchives: true, + openArchivesInTabs: true, ]) } } diff --git a/Modules/Tests/CoreTests/OpenArchivesInTabsTests.swift b/Modules/Tests/CoreTests/OpenArchivesInTabsTests.swift new file mode 100644 index 00000000..c9a6c509 --- /dev/null +++ b/Modules/Tests/CoreTests/OpenArchivesInTabsTests.swift @@ -0,0 +1,23 @@ +// +// OpenArchivesInTabsTests.swift +// Modules +// +// Archives open as tabs of the front window unless the user turns it off in +// Settings ▸ General. +// + +import Foundation +import Testing +@testable import Core + +extension AllCoreTests { + struct OpenArchivesInTabsTests { + + /// What the app registers as it starts is what a first launch reads. + @Test func archivesOpenInTabsUntilTurnedOff() { + Keys.registerDefaults() + let registered = UserDefaults.standard.volatileDomain(forName: UserDefaults.registrationDomain) + #expect(registered[Keys.openArchivesInTabs] as? Bool == true) + } + } +}