Skip to content
Merged
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
194 changes: 194 additions & 0 deletions Copy/Shelf/CardSweep.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import SwiftUI

/// Card frames by item uuid, in the row's content space.
struct CardFramesKey: PreferenceKey {
static var defaultValue: [String: CGRect] { [:] }
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}

/// Drag-select geometry: the rectangle swept from the shelf's empty space, the cards it
/// touches, and edge scrolling. `ShelfViewModel` turns the touched cards into the selection.
/// Points come in the shelf's `"shelfRoot"` space; the rectangle is kept in content space
/// so its starting corner stays with the cards while the row scrolls.
@MainActor
@Observable
final class CardSweep {
static let viewportSpace = "shelfRowViewport"
static let contentSpace = "shelfRowContent"

/// In content space; nil when not sweeping.
private(set) var rect: CGRect?

@ObservationIgnored private var cardFrames: [String: CGRect] = [:]
@ObservationIgnored private var contentOrigin: CGPoint = .zero
/// The row's visible area, in shelf space.
@ObservationIgnored var viewportFrame: CGRect = .zero
/// In content space.
@ObservationIgnored private var start: CGPoint?
/// In shelf space.
@ObservationIgnored private var pointer: CGPoint = .zero
@ObservationIgnored weak var scrollView: NSScrollView?
/// Points per tick; negative scrolls toward the leading edge.
@ObservationIgnored private var edgeScrollStep: CGFloat = 0
@ObservationIgnored private var edgeScrollTimer: Timer?

private static let edgeScrollMargin: CGFloat = 44
private static let edgeScrollMaxStep: CGFloat = 16

var isActive: Bool { start != nil }

/// The lazy row drops off-screen cards, so frames accumulate during a sweep to keep
/// swept cards selected after they scroll away.
func cardFramesChanged(_ frames: [String: CGRect]) {
if isActive {
cardFrames.merge(frames, uniquingKeysWith: { _, new in new })
} else {
cardFrames = frames
}
}

/// Returns false when the press is on a card or a draggable (a pinboard tab).
func begin(at point: CGPoint, avoiding draggables: [CGRect]) -> Bool {
let origin = contentPoint(point)
guard !cardFrames.values.contains(where: { $0.contains(origin) }),
!draggables.contains(where: { $0.contains(point) }) else { return false }
start = origin
pointer = point
refresh()
return true
}

func move(to point: CGPoint) {
guard isActive else { return }
pointer = point
refresh()
}

/// The row scrolled. Returns whether a sweep is in progress.
func contentMoved(to origin: CGPoint) -> Bool {
contentOrigin = origin
guard isActive else { return false }
refresh()
return true
}

func end() {
start = nil
rect = nil
setEdgeScrollStep(0)
}

var rectInShelf: CGRect? {
rect?.offsetBy(dx: contentOrigin.x + viewportFrame.minX, dy: contentOrigin.y + viewportFrame.minY)
}

var hits: Set<String> {
guard let rect else { return [] }
return Set(cardFrames.filter { $0.value.intersects(rect) }.keys)
}

/// The touched card nearest the pointer.
var nearestHit: String? {
guard let rect else { return nil }
let x = contentPoint(pointer).x
return cardFrames
.filter { $0.value.intersects(rect) }
.min(by: { abs($0.value.midX - x) < abs($1.value.midX - x) })?.key
}

private func contentPoint(_ shelfPoint: CGPoint) -> CGPoint {
CGPoint(x: shelfPoint.x - viewportFrame.minX - contentOrigin.x,
y: shelfPoint.y - viewportFrame.minY - contentOrigin.y)
}

private func refresh() {
guard let start else { return }
let current = contentPoint(pointer)
rect = CGRect(x: min(start.x, current.x), y: min(start.y, current.y),
width: abs(current.x - start.x), height: abs(current.y - start.y))
// Depth into an edge zone: 0 at its start, 1 at the edge.
let trailing = (pointer.x - (viewportFrame.maxX - Self.edgeScrollMargin)) / Self.edgeScrollMargin
let leading = ((viewportFrame.minX + Self.edgeScrollMargin) - pointer.x) / Self.edgeScrollMargin
if trailing > 0 {
setEdgeScrollStep(min(trailing, 1) * Self.edgeScrollMaxStep)
} else if leading > 0 {
setEdgeScrollStep(-min(leading, 1) * Self.edgeScrollMaxStep)
} else {
setEdgeScrollStep(0)
}
}

private func setEdgeScrollStep(_ step: CGFloat) {
edgeScrollStep = step
if step == 0 {
edgeScrollTimer?.invalidate()
edgeScrollTimer = nil
} else if edgeScrollTimer == nil {
let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.edgeScrollTick() }
}
// `.common` keeps it firing while the mouse is held down.
RunLoop.main.add(timer, forMode: .common)
edgeScrollTimer = timer
}
}

/// Scrolls the clip view directly, unanimated, so the rectangle tracks it per frame.
private func edgeScrollTick() {
guard let scrollView, let document = scrollView.documentView else { return }
let clip = scrollView.contentView
let maxX = max(0, document.frame.width - clip.bounds.width)
let x = min(max(clip.bounds.origin.x + edgeScrollStep, 0), maxX)
guard x != clip.bounds.origin.x else { return }
clip.setBoundsOrigin(NSPoint(x: x, y: clip.bounds.origin.y))
scrollView.reflectScrolledClipView(clip)
}
}

/// The drag-select rectangle, drawn over the whole shelf.
struct CardSweepRectangle: View {
let sweep: CardSweep

var body: some View {
// A path, not a sized view: a view wider than the shelf gets re-centered by
// layout, which pulls the rectangle away from the pointer once the row scrolls.
let outline = Path(sweep.rectInShelf ?? .zero)
ZStack {
outline.fill(Color.primary.opacity(0.08))
outline.stroke(Color.primary.opacity(0.28), lineWidth: 1)
}
// Never animated, or it trails the pointer.
.transaction { $0.animation = nil }
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}

/// Finds the card row's `NSScrollView` from inside its content.
struct CardRowScrollViewFinder: NSViewRepresentable {
let onFind: (NSScrollView?) -> Void

func makeNSView(context: Context) -> FinderView {
let view = FinderView()
view.onFind = onFind
return view
}

func updateNSView(_ nsView: FinderView, context: Context) {
nsView.onFind = onFind
}

final class FinderView: NSView {
var onFind: ((NSScrollView?) -> Void)?

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.onFind?(self.enclosingScrollView)
}
}
}
}
33 changes: 26 additions & 7 deletions Copy/Shelf/SearchTokenField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ private struct SearchHintPanel: View {
}
.padding(11)
.frame(width: 274, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous).fill(.regularMaterial)
)
.background(DropdownBackdrop(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 0.5)
Expand Down Expand Up @@ -143,10 +141,7 @@ private struct SuggestionsDropdown: View {
}
.padding(4)
.frame(width: 240, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(.regularMaterial)
)
.background(DropdownBackdrop(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 0.5)
Expand Down Expand Up @@ -198,3 +193,27 @@ private struct TokenIcon: View {
}
}
}

/// The frosted backing of the search dropdowns (the hint panel and the suggestions list).
/// They float over the cards, so this blurs the shelf's own content beneath them. SwiftUI's
/// `Material` can't do that on macOS: it samples what is behind the window, so the panel
/// showed the desktop or another app through the shelf instead of the cards under it.
private struct DropdownBackdrop: NSViewRepresentable {
let cornerRadius: CGFloat

func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.material = .popover
view.blendingMode = .withinWindow
view.state = .active
view.wantsLayer = true
view.layer?.cornerRadius = cornerRadius
view.layer?.cornerCurve = .continuous
view.layer?.masksToBounds = true
return view
}

func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
nsView.layer?.cornerRadius = cornerRadius
}
}
23 changes: 13 additions & 10 deletions Copy/Shelf/ShelfPanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ final class ShelfPanelController: NSObject, NSWindowDelegate {
/// the panel's bottom edge, so this carries the same ~7% margin `shelfHeight` gives
/// the standard card row.
static let compactShelfHeight: CGFloat = 244
/// The close animation: how long it takes and how far the shelf sinks while it fades.
private static let closeDuration: TimeInterval = 0.2
private static let closeDrop: CGFloat = 26

var onKeyEvent: ((NSEvent) -> Bool)?
/// Called on every modifier-key change while the shelf is open (⌘-hold hints).
Expand Down Expand Up @@ -312,7 +315,7 @@ final class ShelfPanelController: NSObject, NSWindowDelegate {
if let completion {
pendingHideCompletions.append(completion)
}
// A repeated action during the 180ms fade joins the current close instead of
// A repeated action during the close fade joins the current close instead of
// being dropped or running before the destination app regains focus.
guard !isHiding else { return }
// Keep Copy active until the panel is fully out. On macOS 26 the shelf is live
Expand All @@ -321,7 +324,10 @@ final class ShelfPanelController: NSObject, NSWindowDelegate {
isHiding = true
removeKeyMonitor()

// Mirror of `show()`'s entrance: fade out while sliding down 18pt, then order out.
// The exit: the shelf dims as it sinks a short way, gathering speed, then orders
// out. Only the content view moves, clipped inside a window that stays put: sliding
// the window itself drags live glass across the screen and stutters, and in the
// floating shelf it would show on a display below this one.
// Reduce Motion skips straight to the orderOut, matching the entrance's own gate.
if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
finishHide(panel, restoreFocus: restoreFocus)
Expand All @@ -331,14 +337,11 @@ final class ShelfPanelController: NSObject, NSWindowDelegate {
closeToken += 1
let token = closeToken
NSAnimationContext.runAnimationGroup { context in
context.duration = floatingShelf ? 0.13 : 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
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)
}
context.duration = Self.closeDuration
// Accelerating, with no ease at the end: the shelf leaves, it doesn't settle.
context.timingFunction = CAMediaTimingFunction(controlPoints: 0.4, 0, 1, 1)
panel.animator().alphaValue = 0
shelfContentView(in: panel)?.animator().setFrameOrigin(NSPoint(x: 0, y: -Self.closeDrop))
} completionHandler: { [weak self] in
// NSAnimationContext runs its completion on the main thread; the closure's
// `@Sendable` type just can't see that statically.
Expand Down
52 changes: 51 additions & 1 deletion Copy/Shelf/ShelfRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import UniformTypeIdentifiers
struct ShelfRootView: View {
@Bindable var viewModel: ShelfViewModel
@State private var permissionBannerDismissed = false
/// Where the shelf's press-and-drag stands: a press on a card or tab is refused as a
/// drag-select for the rest of that drag, since it drags that card or tab instead.
@State private var sweepGesture = SweepGesture.idle

private enum SweepGesture { case idle, sweeping, refused }
/// Persisted so the keyboard legend, once dismissed, stays gone. Read once here;
/// `dismissLegend()` writes it back. Defaults to shown (false) for new users.
@State private var legendDismissed = UserDefaults.standard.bool(forKey: Self.legendDismissedKey)
Expand Down Expand Up @@ -66,6 +71,25 @@ struct ShelfRootView: View {
// PinboardTabFramesKey below.
.coordinateSpace(name: "shelfRoot")
.onPreferenceChange(PinboardTabFramesKey.self) { viewModel.pinboardTabFrames = $0 }
// Drag-select: press in the shelf's empty space — the card row or the header — and
// sweep over cards. A press on a card or a pinboard tab never starts one
// (`beginCardSweep` refuses it), so those keep their own click and drag. `.local`
// here is the "shelfRoot" space.
.gesture(
DragGesture(minimumDistance: 4, coordinateSpace: .local)
.onChanged { value in
if sweepGesture == .idle {
viewModel.beginCardSweep(at: value.startLocation, modifiers: NSEvent.modifierFlags)
sweepGesture = viewModel.cardSweep.isActive ? .sweeping : .refused
}
if sweepGesture == .sweeping { viewModel.moveCardSweep(to: value.location) }
}
.onEnded { _ in
if sweepGesture == .sweeping { viewModel.endCardSweep() }
sweepGesture = .idle
}
)
.overlay { CardSweepRectangle(sweep: viewModel.cardSweep) }
// Pops out of the tab under a card drag to name the board it will land in.
.overlay {
PinboardDropCalloutLayer(callout: viewModel.dropCallout,
Expand Down Expand Up @@ -574,6 +598,16 @@ private struct ShelfItemsRow: View {
dragBadgeCount: viewModel.isSelected(item) ? viewModel.selection.selected.count : 1
)
.id(item.uuid)
// Publish the card's frame so a drag-select can tell which
// cards its rectangle touches (see `CardSweep`).
.background(
GeometryReader { geo in
Color.clear.preference(
key: CardFramesKey.self,
value: [item.uuid: geo.frame(in: .named(CardSweep.contentSpace))]
)
}
)
// The LazyHStack only builds a card once it scrolls into view,
// so this fires as the user nears the oldest card and widens the
// fetch window. Without it the shelf stopped at the first page.
Expand All @@ -600,9 +634,25 @@ private struct ShelfItemsRow: View {
// enough to create the hit-testable NSView; `contentShape` alone (which
// affects gesture hit-testing, not AppKit scroll-wheel routing) wasn't.
.background(Color.black.opacity(0.001))
// Gives the drag-select the row's scroll view, which it scrolls while
// the pointer is held at an edge.
.background(CardRowScrollViewFinder { viewModel.cardSweep.scrollView = $0 })
.coordinateSpace(name: CardSweep.contentSpace)
.onGeometryChange(for: CGPoint.self) {
$0.frame(in: .named(CardSweep.viewportSpace)).origin
} action: {
viewModel.cardRowContentMoved(to: $0)
}
}
.coordinateSpace(name: CardSweep.viewportSpace)
.onGeometryChange(for: CGRect.self) { $0.frame(in: .named("shelfRoot")) } action: {
viewModel.cardSweep.viewportFrame = $0
}
.onPreferenceChange(CardFramesKey.self) { viewModel.cardSweep.cardFramesChanged($0) }
.onChange(of: viewModel.selection.primary) { _, newPrimary in
if let newPrimary {
// A sweep moves the primary with the pointer; centering on it would
// scroll the row out from under the rectangle.
if let newPrimary, !viewModel.cardSweep.isActive {
if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
proxy.scrollTo(newPrimary, anchor: .center)
} else {
Expand Down
Loading
Loading