From 5f6521d58977e7259584127b2c690721170313f2 Mon Sep 17 00:00:00 2001 From: Nikolai Berezovskii Date: Tue, 15 Sep 2026 18:56:15 +0300 Subject: [PATCH] Make the floating shelf an optional presentation style Signed-off-by: Nikolai Berezovskii --- Copy/App/AppCoordinator.swift | 4 + Copy/Settings/GeneralSettings.swift | 12 ++ Copy/Settings/SettingsStore.swift | 11 ++ Copy/Shelf/ShelfPanelController.swift | 108 ++++++++++++++---- Copy/Shelf/ShelfRootView.swift | 9 +- .../Sources/CopyCore/Models/ShelfLayout.swift | 9 ++ .../CopyCoreTests/ShelfLayoutTests.swift | 21 ++++ 7 files changed, 151 insertions(+), 23 deletions(-) create mode 100644 CopyCore/Sources/CopyCore/Models/ShelfLayout.swift create mode 100644 CopyCore/Tests/CopyCoreTests/ShelfLayoutTests.swift diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index cadf9fb..f1b8870 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -52,6 +52,7 @@ final class AppCoordinator { let controller = ShelfPanelController( hideDuringScreenSharing: settings.hideDuringScreenSharing, compactShelf: settings.compactShelf, + floatingShelf: settings.floatingShelf, proDark: settings.shelfProDark) { [weak self] in guard let self else { return NSView() } return NSHostingView(rootView: ShelfRootView(viewModel: self.shelfViewModel)) @@ -421,6 +422,9 @@ final class AppCoordinator { self?.shelfController.setHideDuringScreenSharing(hide) self?.pasteStackController.setHideDuringScreenSharing(hide) } + settings.onFloatingShelfChange = { [weak self] floating in + self?.shelfController.setFloatingShelf(floating) + } settings.onCompactShelfChange = { [weak self] compact in self?.shelfController.setCompactShelf(compact) } diff --git a/Copy/Settings/GeneralSettings.swift b/Copy/Settings/GeneralSettings.swift index 56d1784..b0456eb 100644 --- a/Copy/Settings/GeneralSettings.swift +++ b/Copy/Settings/GeneralSettings.swift @@ -33,6 +33,18 @@ struct GeneralSettings: View { .foregroundStyle(.secondary) } + Section { + Picker("Shelf Style", selection: $settings.floatingShelf) { + Text("Edge Attached").tag(false) + Text("Floating").tag(true) + } + .pickerStyle(.segmented) + } footer: { + Text("Floating adds space around the shelf and rounds every corner.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Section { Toggle("Always Use Dark Shelf", isOn: $settings.shelfProDark) } footer: { diff --git a/Copy/Settings/SettingsStore.swift b/Copy/Settings/SettingsStore.swift index 40a3983..6d3d565 100644 --- a/Copy/Settings/SettingsStore.swift +++ b/Copy/Settings/SettingsStore.swift @@ -80,6 +80,7 @@ final class SettingsStore { static let recognizeImageTextKey = "recognizeImageText" static let excludedBundleIDsKey = "excludedBundleIDs" static let hideDuringScreenSharingKey = "hideDuringScreenSharing" + static let floatingShelfKey = "floatingShelf" static let compactShelfKey = "compactShelf" static let shelfProDarkKey = "shelfProDark" static let hideMenuBarIconKey = "hideMenuBarIcon" @@ -141,6 +142,15 @@ final class SettingsStore { } } + var floatingShelf: Bool { + didSet { + guard floatingShelf != oldValue else { return } + defaults.set(floatingShelf, forKey: Self.floatingShelfKey) + onFloatingShelfChange?(floatingShelf) + } + } + @ObservationIgnored var onFloatingShelfChange: ((Bool) -> Void)? + /// A fixed "pro dark" look for the shelf and paste stack: a forced dark appearance /// plus an electric-blue accent, regardless of the system appearance or accent color /// (so the app matches its own marketing look). Off by default, so the shelf follows @@ -224,6 +234,7 @@ final class SettingsStore { fetchLinkPreviews = (defaults.object(forKey: Self.fetchLinkPreviewsKey) as? Bool) ?? true recognizeImageText = (defaults.object(forKey: Self.recognizeImageTextKey) as? Bool) ?? true hideDuringScreenSharing = (defaults.object(forKey: Self.hideDuringScreenSharingKey) as? Bool) ?? false + floatingShelf = (defaults.object(forKey: Self.floatingShelfKey) as? Bool) ?? false compactShelf = (defaults.object(forKey: Self.compactShelfKey) as? Bool) ?? false shelfProDark = (defaults.object(forKey: Self.shelfProDarkKey) as? Bool) ?? false hideMenuBarIcon = (defaults.object(forKey: Self.hideMenuBarIconKey) as? Bool) ?? false diff --git a/Copy/Shelf/ShelfPanelController.swift b/Copy/Shelf/ShelfPanelController.swift index aa7d72f..ecbd0f7 100644 --- a/Copy/Shelf/ShelfPanelController.swift +++ b/Copy/Shelf/ShelfPanelController.swift @@ -1,4 +1,5 @@ import AppKit +import CopyCore final class KeyablePanel: NSPanel { override var canBecomeKey: Bool { true } @@ -17,6 +18,35 @@ final class KeyablePanel: NSPanel { } } +/// Keeps the shelf's contents inside the panel while they slide. This matters when the +/// shelf lives on a display with another display below it: moving the window itself past +/// the upper display's edge makes it visible on the lower one, while moving this child +/// view is clipped to the stationary window. +private final class ShelfClippingView: NSView { + let shelfContent: NSView + + init(content: NSView) { + shelfContent = content + super.init(frame: .zero) + wantsLayer = true + layer?.masksToBounds = true + addSubview(content) + content.autoresizingMask = [.width, .height] + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + var contentFrame = shelfContent.frame + contentFrame.size = bounds.size + shelfContent.frame = contentFrame + } +} + @MainActor final class ShelfPanelController: NSObject, NSWindowDelegate { static let shelfHeight: CGFloat = 352 @@ -51,12 +81,14 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { /// handler doesn't order the panel out after a newer show/hide has superseded it. private var closeToken = 0 private var hideDuringScreenSharing: Bool + private var floatingShelf: Bool private var compactShelf: Bool private var proDark: Bool - init(hideDuringScreenSharing: Bool, compactShelf: Bool, proDark: Bool, makeContent: @escaping () -> NSView) { + init(hideDuringScreenSharing: Bool, compactShelf: Bool, floatingShelf: Bool, proDark: Bool, makeContent: @escaping () -> NSView) { self.hideDuringScreenSharing = hideDuringScreenSharing self.compactShelf = compactShelf + self.floatingShelf = floatingShelf self.proDark = proDark self.makeContent = makeContent } @@ -93,13 +125,18 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { let screen = NSScreen.screens.first(where: { NSMouseInRect(NSEvent.mouseLocation, $0.frame, false) }) ?? NSScreen.main else { return } - let frame = NSRect(x: screen.visibleFrame.minX, - y: screen.visibleFrame.minY, - width: screen.visibleFrame.width, - height: currentShelfHeight) + let frame = ShelfLayout.frame(visibleFrame: screen.visibleFrame, height: currentShelfHeight, floating: floatingShelf) panel.setFrame(frame, display: true) } + func setFloatingShelf(_ floating: Bool) { + floatingShelf = floating + guard let panel, panel.isVisible, !isHiding, let screen = panel.screen else { return } + setShelfContentOffset(0, in: panel) + panel.setFrame(ShelfLayout.frame(visibleFrame: screen.visibleFrame, + height: currentShelfHeight, floating: floating), display: true) + } + var isVisible: Bool { panel?.isVisible ?? false } func toggle() { @@ -112,10 +149,7 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { NSMouseInRect(NSEvent.mouseLocation, $0.frame, false) }) ?? NSScreen.main else { return } - let frame = NSRect(x: screen.visibleFrame.minX, - y: screen.visibleFrame.minY, - width: screen.visibleFrame.width, - height: currentShelfHeight) + let frame = ShelfLayout.frame(visibleFrame: screen.visibleFrame, height: currentShelfHeight, floating: floatingShelf) let panel = self.panel ?? makePanel() self.panel = panel @@ -131,15 +165,32 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { pendingHideCompletions.removeAll() let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion - panel.setFrame(reduceMotion ? frame : frame.offsetBy(dx: 0, dy: -24), display: false) - panel.alphaValue = 0 - panel.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.22 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - panel.animator().alphaValue = 1 - panel.animator().setFrame(frame, display: true) + setShelfContentOffset(0, in: panel) + if floatingShelf { + // Keep the window on its display: only its clipped content slides, so it + // cannot leak onto another monitor placed below this one. + panel.setFrame(frame, display: false) + panel.alphaValue = 1 + setShelfContentOffset(reduceMotion ? 0 : -frame.height, in: panel) + panel.makeKeyAndOrderFront(nil) + if !reduceMotion { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.13 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + shelfContentView(in: panel)?.animator().setFrameOrigin(.zero) + } + } + } else { + panel.setFrame(reduceMotion ? frame : frame.offsetBy(dx: 0, dy: -24), display: false) + panel.alphaValue = 0 + panel.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + NSAnimationContext.runAnimationGroup { context in + context.duration = reduceMotion ? 0 : 0.22 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 1 + panel.animator().setFrame(frame, display: true) + } } installKeyMonitor() // The shelf opens in browse mode: keep the search field from auto-becoming first @@ -177,10 +228,14 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { closeToken += 1 let token = closeToken NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 + context.duration = floatingShelf ? 0.13 : 0.18 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - panel.animator().alphaValue = 0 - panel.animator().setFrame(panel.frame.offsetBy(dx: 0, dy: -18), display: true) + if floatingShelf { + shelfContentView(in: panel)?.animator().setFrameOrigin(NSPoint(x: 0, y: -panel.frame.height)) + } else { + panel.animator().alphaValue = 0 + panel.animator().setFrame(panel.frame.offsetBy(dx: 0, dy: -18), display: true) + } } completionHandler: { [weak self] in // NSAnimationContext runs its completion on the main thread; the closure's // `@Sendable` type just can't see that statically. @@ -197,6 +252,7 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { /// still fading out. private func finishHide(_ panel: KeyablePanel, restoreFocus: Bool) { panel.orderOut(nil) + setShelfContentOffset(0, in: panel) // Leave alpha at zero while hidden. Resetting it to one in the same run-loop // turn as `orderOut` can race WindowServer and briefly re-show the fully opaque // surface. `show()` always establishes its own alpha-zero starting state. @@ -212,6 +268,14 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { // MARK: - Private + private func shelfContentView(in panel: KeyablePanel) -> NSView? { + (panel.contentView as? ShelfClippingView)?.shelfContent + } + + private func setShelfContentOffset(_ offset: CGFloat, in panel: KeyablePanel) { + shelfContentView(in: panel)?.setFrameOrigin(NSPoint(x: 0, y: offset)) + } + private func makePanel() -> KeyablePanel { let panel = KeyablePanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], @@ -225,7 +289,7 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { panel.becomesKeyOnlyIfNeeded = false panel.isFloatingPanel = true panel.delegate = self - panel.contentView = makeContent() + panel.contentView = ShelfClippingView(content: makeContent()) panel.sharingType = hideDuringScreenSharing ? .none : .readOnly panel.childWindowSharingType = hideDuringScreenSharing ? .none : .readOnly return panel diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index 7e5889d..1f2ef41 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -55,7 +55,14 @@ struct ShelfRootView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .glassSurface(corners: .top(12)) + .glassSurface(corners: viewModel.settings.floatingShelf ? .all(12) : .top(12)) + .clipShape(UnevenRoundedRectangle( + topLeadingRadius: 12, + bottomLeadingRadius: viewModel.settings.floatingShelf ? 12 : 0, + bottomTrailingRadius: viewModel.settings.floatingShelf ? 12 : 0, + topTrailingRadius: 12, + style: .continuous + )) // Card → pinboard filing is handled here, at the shelf root, because a per-tab // `.onDrop` never establishes a working drop region on the small pills inside this // borderless non-activating glass panel (a shelf-level drop, by contrast, fires diff --git a/CopyCore/Sources/CopyCore/Models/ShelfLayout.swift b/CopyCore/Sources/CopyCore/Models/ShelfLayout.swift new file mode 100644 index 0000000..71f29c0 --- /dev/null +++ b/CopyCore/Sources/CopyCore/Models/ShelfLayout.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum ShelfLayout { + public static func frame(visibleFrame: CGRect, height: CGFloat, floating: Bool) -> CGRect { + let inset: CGFloat = floating ? 12 : 0 + return CGRect(x: visibleFrame.minX + inset, y: visibleFrame.minY + inset, + width: max(1, visibleFrame.width - inset * 2), height: height) + } +} diff --git a/CopyCore/Tests/CopyCoreTests/ShelfLayoutTests.swift b/CopyCore/Tests/CopyCoreTests/ShelfLayoutTests.swift new file mode 100644 index 0000000..1b662ea --- /dev/null +++ b/CopyCore/Tests/CopyCoreTests/ShelfLayoutTests.swift @@ -0,0 +1,21 @@ +import XCTest +@testable import CopyCore + +final class ShelfLayoutTests: XCTestCase { + func testAttachedGeometryStaysUnchanged() { + let screen = CGRect(x: -1920, y: 900, width: 1920, height: 1080) + for height: CGFloat in [244, 352] { + XCTAssertEqual(ShelfLayout.frame(visibleFrame: screen, height: height, floating: false), + CGRect(x: -1920, y: 900, width: 1920, height: height)) + } + } + func testFloatingFrameKeepsItsGapsOnOffsetDisplays() { + for screen in [CGRect(x: 0, y: 0, width: 1440, height: 900), CGRect(x: -1920, y: 900, width: 1920, height: 1080)] { + let frame = ShelfLayout.frame(visibleFrame: screen, height: 352, floating: true) + XCTAssertEqual(frame.minX - screen.minX, 12) + XCTAssertEqual(screen.maxX - frame.maxX, 12) + XCTAssertEqual(frame.minY - screen.minY, 12) + XCTAssertTrue(screen.contains(frame)) + } + } +}