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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Copy/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}
Expand Down
12 changes: 12 additions & 0 deletions Copy/Settings/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
11 changes: 11 additions & 0 deletions Copy/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
108 changes: 86 additions & 22 deletions Copy/Shelf/ShelfPanelController.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CopyCore

final class KeyablePanel: NSPanel {
override var canBecomeKey: Bool { true }
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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() {
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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],
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion Copy/Shelf/ShelfRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CopyCore/Sources/CopyCore/Models/ShelfLayout.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 21 additions & 0 deletions CopyCore/Tests/CopyCoreTests/ShelfLayoutTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}