diff --git a/Copy/Shelf/CardSweep.swift b/Copy/Shelf/CardSweep.swift new file mode 100644 index 0000000..6c133f6 --- /dev/null +++ b/Copy/Shelf/CardSweep.swift @@ -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 { + 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) + } + } + } +} diff --git a/Copy/Shelf/SearchTokenField.swift b/Copy/Shelf/SearchTokenField.swift index 5e74477..366497f 100644 --- a/Copy/Shelf/SearchTokenField.swift +++ b/Copy/Shelf/SearchTokenField.swift @@ -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) @@ -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) @@ -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 + } +} diff --git a/Copy/Shelf/ShelfPanelController.swift b/Copy/Shelf/ShelfPanelController.swift index 78c24fd..11339d9 100644 --- a/Copy/Shelf/ShelfPanelController.swift +++ b/Copy/Shelf/ShelfPanelController.swift @@ -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). @@ -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 @@ -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) @@ -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. diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index 94196ad..cf1c5b6 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -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) @@ -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, @@ -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. @@ -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 { diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index 21b0e30..314fc93 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -78,6 +78,13 @@ final class ShelfViewModel { } var pinboards: [Pinboard] = [] var selection = ShelfSelection() + /// The drag-select in the card row: its rectangle and which cards it touches. The + /// selection it produces is applied in `updateCardSweep`. + let cardSweep = CardSweep() + /// The selection as it was when the sweep began: what a sweep that touches no card + /// leaves in place, and what a ⌘/Shift sweep adds to. + @ObservationIgnored private var sweepBaseSelection = ShelfSelection() + @ObservationIgnored private var sweepAddsToSelection = false var previewShown = false var editingItem: ClipItem? var pinboardPopoverShown = false @@ -112,6 +119,9 @@ final class ShelfViewModel { /// fallback for when the drag pasteboard can't name or hand over the payload yet. /// Cleared once the drag ends over the shelf, and replaced by the next drag. @ObservationIgnored private(set) var activeShelfDrag: ShelfDrag? + /// The card the pointer picked up in the most recent card drag — the one card of a + /// dragged selection that stays highlighted after it is filed into a pinboard. + @ObservationIgnored private var grabbedCardUUID: String? /// Set by a force-click (which fires before the click's own mouse-up resolves) so the /// release doesn't then paste the card. Consumed by the next `handleCardClick`. @ObservationIgnored var suppressNextCardPaste = false @@ -399,6 +409,7 @@ final class ShelfViewModel { focusSearchRequested = false shelfDragEnded() dropCallout = nil + cardSweep.end() endSearchEditingRequested = false } @@ -442,6 +453,40 @@ final class ShelfViewModel { } } + /// A press-and-drag began in the shelf at `point` (the `"shelfRoot"` space). Starts a + /// drag-select unless the press is on a card or a pinboard tab, which drags that instead. + /// Holding ⌘ or Shift adds the swept cards to the selection; otherwise they replace it. + func beginCardSweep(at point: CGPoint, modifiers: NSEvent.ModifierFlags) { + guard cardSweep.begin(at: point, avoiding: Array(pinboardTabFrames.values)) else { return } + sweepBaseSelection = selection + sweepAddsToSelection = !modifiers.isDisjoint(with: [.command, .shift]) + } + + func moveCardSweep(to point: CGPoint) { + cardSweep.move(to: point) + updateCardSweep() + } + + /// The card row scrolled, so a sweep in progress now reaches different cards. + func cardRowContentMoved(to origin: CGPoint) { + if cardSweep.contentMoved(to: origin) { updateCardSweep() } + } + + func endCardSweep() { + cardSweep.end() + } + + /// Selects the cards the sweep rectangle touches. The primary follows the pointer, so + /// ⏎/Space act on the card it reached last. + private func updateCardSweep() { + guard cardSweep.isActive else { return } + var swept = cardSweep.hits + if sweepAddsToSelection { swept.formUnion(sweepBaseSelection.selected) } + var updated = sweepBaseSelection + updated.select(swept, preferredPrimary: cardSweep.nearestHit, in: items.map(\.uuid)) + if updated != selection { selection = updated } + } + func moveSelection(_ delta: Int) { selection.move(delta, in: items.map(\.uuid)) } @@ -881,6 +926,12 @@ final class ShelfViewModel { } let added = dropItems(uuids: uuids, toPinboard: pinboard) Haptics.snap() + // The dragged cards are filed, so let go of the multi-selection rather than leave + // one the user has to click away. The board opens with only the card they were + // holding highlighted, which shows where the drop landed. + if let held = grabbedCardUUID.flatMap({ uuids.contains($0) ? $0 : nil }) ?? uuids.first { + selection.click(held) + } tab = .pinboard(id) if added > 0 { showFiledCallout(pinboardID: id, count: added) } return true @@ -1087,6 +1138,7 @@ final class ShelfViewModel { if !selection.selected.contains(item.uuid) { selection.click(item.uuid) } + grabbedCardUUID = item.uuid Haptics.tap() if selection.selected.count > 1 { activeShelfDrag = .cards(orderedSelectedItems.map(\.uuid)) diff --git a/CopyCore/Sources/CopyCore/Storage/ShelfSelection.swift b/CopyCore/Sources/CopyCore/Storage/ShelfSelection.swift index 82d73e1..9199cef 100644 --- a/CopyCore/Sources/CopyCore/Storage/ShelfSelection.swift +++ b/CopyCore/Sources/CopyCore/Storage/ShelfSelection.swift @@ -35,6 +35,20 @@ public struct ShelfSelection: Equatable, Sendable { selected = Set(order[range]) } + /// Replaces the selection with `uuids`, the cards a drag-select sweep covers. The + /// primary becomes `preferredPrimary` when that card is in the set, else stays where + /// it was if still selected, else falls to the first selected card in `order`. An + /// empty set changes nothing: the shelf always keeps a selection. + public mutating func select(_ uuids: Set, preferredPrimary: String? = nil, in order: [String]) { + guard !uuids.isEmpty else { return } + selected = uuids + if let preferredPrimary, uuids.contains(preferredPrimary) { + primary = preferredPrimary + } else if primary.map(uuids.contains) != true { + primary = order.first(where: uuids.contains) ?? uuids.first + } + } + public mutating func move(_ delta: Int, in order: [String]) { guard !order.isEmpty else { return } guard let current = primary, let index = order.firstIndex(of: current) else { diff --git a/CopyCore/Tests/CopyCoreTests/ShelfSelectionTests.swift b/CopyCore/Tests/CopyCoreTests/ShelfSelectionTests.swift index b6d828d..34b67a0 100644 --- a/CopyCore/Tests/CopyCoreTests/ShelfSelectionTests.swift +++ b/CopyCore/Tests/CopyCoreTests/ShelfSelectionTests.swift @@ -34,6 +34,38 @@ final class ShelfSelectionTests: XCTestCase { XCTAssertEqual(sel.selected, ["a", "b"]) } + func testSelectReplacesSelectionAndPrefersGivenPrimary() { + var sel = ShelfSelection() + sel.click("a") + sel.select(["c", "d", "e"], preferredPrimary: "d", in: order) + XCTAssertEqual(sel.selected, ["c", "d", "e"]) + XCTAssertEqual(sel.primary, "d") + } + + func testSelectKeepsPrimaryWhenStillSelected() { + var sel = ShelfSelection() + sel.click("c") + sel.select(["b", "c"], in: order) + XCTAssertEqual(sel.selected, ["b", "c"]) + XCTAssertEqual(sel.primary, "c") + } + + func testSelectFallsBackToFirstSelectedInOrder() { + var sel = ShelfSelection() + sel.click("a") + sel.select(["e", "c"], preferredPrimary: "b", in: order) + XCTAssertEqual(sel.selected, ["c", "e"]) + XCTAssertEqual(sel.primary, "c") + } + + func testSelectEmptySetChangesNothing() { + var sel = ShelfSelection() + sel.click("b") + sel.select([], in: order) + XCTAssertEqual(sel.selected, ["b"]) + XCTAssertEqual(sel.primary, "b") + } + func testMoveCollapsesAndClamps() { var sel = ShelfSelection() sel.click("b")