From 3260b2235a31986beec7dca774ce9abc54b57ab6 Mon Sep 17 00:00:00 2001 From: devansh nigam Date: Fri, 2 Oct 2026 21:38:05 +0530 Subject: [PATCH 1/3] feat: added the drag to select along with some minor fixes Signed-off-by: devansh nigam --- Copy/Shelf/CardSweep.swift | 167 ++++++++++++++++++ Copy/Shelf/SearchTokenField.swift | 33 +++- Copy/Shelf/ShelfPanelController.swift | 23 +-- Copy/Shelf/ShelfRootView.swift | 59 ++++++- Copy/Shelf/ShelfViewModel.swift | 52 ++++++ .../CopyCore/Storage/ShelfSelection.swift | 14 ++ .../CopyCoreTests/ShelfSelectionTests.swift | 32 ++++ 7 files changed, 362 insertions(+), 18 deletions(-) create mode 100644 Copy/Shelf/CardSweep.swift diff --git a/Copy/Shelf/CardSweep.swift b/Copy/Shelf/CardSweep.swift new file mode 100644 index 0000000..37a5f7b --- /dev/null +++ b/Copy/Shelf/CardSweep.swift @@ -0,0 +1,167 @@ +import SwiftUI + +/// Frames of the cards currently built in the card row, keyed by item uuid, in the row's +/// content space (`CardSweep.contentSpace`), which scrolls with the cards. The row is +/// lazy, so this only ever holds the cards on or near the screen. +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 }) + } +} + +/// The geometry of a drag-select: pressing in the shelf's empty space — in the card row or +/// up in the header beside the tabs — and sweeping a rectangle over cards selects them, as +/// in Finder. Pressing on a card or a pinboard tab is not a sweep; that drags it instead. +/// +/// This holds only the geometry — the rectangle, which cards it touches, and whether the +/// pointer is parked at an edge so the row should scroll. `ShelfViewModel` turns the cards +/// it touches into the selection. The pointer is reported in the shelf's `"shelfRoot"` +/// space; the rectangle is kept in the row's content space, so its starting corner stays +/// with the cards it started beside while the row scrolls under the pointer. +@MainActor +@Observable +final class CardSweep { + /// Coordinate space of the scroll view's visible area. + static let viewportSpace = "shelfRowViewport" + /// Coordinate space of the scrolling content; card frames and `rect` are measured here. + static let contentSpace = "shelfRowContent" + + /// The selection rectangle, in content space, while a sweep is in progress. + private(set) var rect: CGRect? + /// -1 or 1 while the pointer is held at the row's leading or trailing edge, where the + /// row scrolls to bring more cards under the sweep; 0 otherwise. + private(set) var edgeScroll = 0 + + @ObservationIgnored private var cardFrames: [String: CGRect] = [:] + @ObservationIgnored private var contentOrigin: CGPoint = .zero + /// The card row's visible area, in the shelf's `"shelfRoot"` space. + @ObservationIgnored var viewportFrame: CGRect = .zero + /// Where the sweep started, in content space. Non-nil while sweeping. + @ObservationIgnored private var start: CGPoint? + /// The pointer's latest position, in the shelf's space. + @ObservationIgnored private var pointer: CGPoint = .zero + + /// How close to the row's edge the pointer has to be for the row to scroll. + private static let edgeScrollMargin: CGFloat = 28 + + var isActive: Bool { start != nil } + + /// The row's lazy stack drops cards that scroll away, so during a sweep the frames are + /// accumulated: a card swept earlier stays selected after it leaves the screen. + func cardFramesChanged(_ frames: [String: CGRect]) { + if isActive { + cardFrames.merge(frames, uniquingKeysWith: { _, new in new }) + } else { + cardFrames = frames + } + } + + /// Starts a sweep at `point` (shelf space). Returns false, starting nothing, when the + /// press is on a card or inside one of `draggables` (the pinboard tabs, in shelf space). + 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 content moved inside the viewport (the row scrolled). Returns whether a sweep is + /// in progress, in which case the rectangle now reaches different cards. + func contentMoved(to origin: CGPoint) -> Bool { + contentOrigin = origin + guard isActive else { return false } + refresh() + return true + } + + func end() { + start = nil + rect = nil + edgeScroll = 0 + } + + /// The selection rectangle in the shelf's space, for drawing over the whole shelf. + var rectInShelf: CGRect? { + rect?.offsetBy(dx: contentOrigin.x + viewportFrame.minX, dy: contentOrigin.y + viewportFrame.minY) + } + + /// The cards the rectangle touches. + var hits: Set { + guard let rect else { return [] } + return Set(cardFrames.filter { $0.value.intersects(rect) }.keys) + } + + /// Of the cards the rectangle touches, the one 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 + } + + /// While the pointer is held at an edge: the next card past that edge, in `order`, to + /// scroll into view. Nil when there is none or the pointer isn't at an edge. + func scrollTarget(in order: [String]) -> String? { + guard edgeScroll != 0 else { return nil } + let fullyVisible = order.indices.filter { index in + guard let frame = cardFrames[order[index]] else { return false } + return frame.minX + contentOrigin.x >= 0 && frame.maxX + contentOrigin.x <= viewportFrame.width + } + let next = edgeScroll > 0 ? fullyVisible.last.map { $0 + 1 } : fullyVisible.first.map { $0 - 1 } + guard let next, order.indices.contains(next) else { return nil } + return order[next] + } + + 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)) + if pointer.x > viewportFrame.maxX - Self.edgeScrollMargin { + edgeScroll = 1 + } else if pointer.x < viewportFrame.minX + Self.edgeScrollMargin { + edgeScroll = -1 + } else { + edgeScroll = 0 + } + } +} + +/// The drag-select rectangle, drawn over the whole shelf (in its `"shelfRoot"` space) so a +/// sweep that starts in the header shows from there. Its own view so only it redraws as +/// the rectangle changes. +struct CardSweepRectangle: View { + let sweep: CardSweep + + var body: some View { + ZStack(alignment: .topLeading) { + Color.clear + if let rect = sweep.rectInShelf { + // Neutral, like Finder's: the cards' own selected look carries the result. + Rectangle() + .fill(Color.primary.opacity(0.08)) + .overlay(Rectangle().strokeBorder(Color.primary.opacity(0.28), lineWidth: 1)) + .frame(width: rect.width, height: rect.height) + .offset(x: rect.minX, y: rect.minY) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} 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..4bc5b10 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,32 @@ 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)) + .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) } + // While the sweep is held at an edge, step the row along a card at a time. + .task(id: viewModel.cardSweep.edgeScroll) { + while !Task.isCancelled, viewModel.cardSweep.edgeScroll != 0 { + if let target = viewModel.cardSweep.scrollTarget(in: viewModel.items.map(\.uuid)) { + let anchor: UnitPoint = viewModel.cardSweep.edgeScroll > 0 ? .trailing : .leading + withAnimation(.linear(duration: 0.18)) { proxy.scrollTo(target, anchor: anchor) } + } + try? await Task.sleep(for: .milliseconds(180)) + } } .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") From 00d3daa48c4655dd5aaa328ced0b85ff560fc0df Mon Sep 17 00:00:00 2001 From: devansh nigam Date: Fri, 2 Oct 2026 22:21:49 +0530 Subject: [PATCH 2/3] feat: dragg issues fixes Signed-off-by: devansh nigam --- Copy/Shelf/CardSweep.swift | 122 ++++++++++++++++++++++++--------- Copy/Shelf/ShelfRootView.swift | 13 +--- 2 files changed, 92 insertions(+), 43 deletions(-) diff --git a/Copy/Shelf/CardSweep.swift b/Copy/Shelf/CardSweep.swift index 37a5f7b..f5aa50a 100644 --- a/Copy/Shelf/CardSweep.swift +++ b/Copy/Shelf/CardSweep.swift @@ -29,9 +29,6 @@ final class CardSweep { /// The selection rectangle, in content space, while a sweep is in progress. private(set) var rect: CGRect? - /// -1 or 1 while the pointer is held at the row's leading or trailing edge, where the - /// row scrolls to bring more cards under the sweep; 0 otherwise. - private(set) var edgeScroll = 0 @ObservationIgnored private var cardFrames: [String: CGRect] = [:] @ObservationIgnored private var contentOrigin: CGPoint = .zero @@ -41,9 +38,18 @@ final class CardSweep { @ObservationIgnored private var start: CGPoint? /// The pointer's latest position, in the shelf's space. @ObservationIgnored private var pointer: CGPoint = .zero + /// The card row's scroll view, scrolled directly while the pointer is held at an edge. + @ObservationIgnored weak var scrollView: NSScrollView? + /// Points to scroll per tick while the pointer is in an edge zone: negative toward + /// the leading edge, positive toward the trailing one, 0 when it is in neither. + @ObservationIgnored private var edgeScrollStep: CGFloat = 0 + @ObservationIgnored private var edgeScrollTimer: Timer? /// How close to the row's edge the pointer has to be for the row to scroll. - private static let edgeScrollMargin: CGFloat = 28 + private static let edgeScrollMargin: CGFloat = 44 + /// The fastest the row scrolls, in points per tick (60 ticks a second), reached when + /// the pointer is at or past the edge. It eases in from the start of the edge zone. + private static let edgeScrollMaxStep: CGFloat = 16 var isActive: Bool { start != nil } @@ -87,7 +93,7 @@ final class CardSweep { func end() { start = nil rect = nil - edgeScroll = 0 + setEdgeScrollStep(0) } /// The selection rectangle in the shelf's space, for drawing over the whole shelf. @@ -110,19 +116,6 @@ final class CardSweep { .min(by: { abs($0.value.midX - x) < abs($1.value.midX - x) })?.key } - /// While the pointer is held at an edge: the next card past that edge, in `order`, to - /// scroll into view. Nil when there is none or the pointer isn't at an edge. - func scrollTarget(in order: [String]) -> String? { - guard edgeScroll != 0 else { return nil } - let fullyVisible = order.indices.filter { index in - guard let frame = cardFrames[order[index]] else { return false } - return frame.minX + contentOrigin.x >= 0 && frame.maxX + contentOrigin.x <= viewportFrame.width - } - let next = edgeScroll > 0 ? fullyVisible.last.map { $0 + 1 } : fullyVisible.first.map { $0 - 1 } - guard let next, order.indices.contains(next) else { return nil } - return order[next] - } - private func contentPoint(_ shelfPoint: CGPoint) -> CGPoint { CGPoint(x: shelfPoint.x - viewportFrame.minX - contentOrigin.x, y: shelfPoint.y - viewportFrame.minY - contentOrigin.y) @@ -133,14 +126,46 @@ final class CardSweep { 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)) - if pointer.x > viewportFrame.maxX - Self.edgeScrollMargin { - edgeScroll = 1 - } else if pointer.x < viewportFrame.minX + Self.edgeScrollMargin { - edgeScroll = -1 + // How far into an edge zone the pointer is: 0 at the zone's 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 { - edgeScroll = 0 + setEdgeScrollStep(0) } } + + /// Runs the edge-scroll timer only while there is something to scroll. + 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` so it keeps firing while the mouse is held down in the drag. + RunLoop.main.add(timer, forMode: .common) + edgeScrollTimer = timer + } + } + + /// Scrolls the row a small step, the way a trackpad scroll would: continuous and + /// unanimated, so the rectangle and the selection follow it frame by frame (through + /// `contentMoved`) instead of chasing an animation. + 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 (in its `"shelfRoot"` space) so a @@ -150,18 +175,49 @@ struct CardSweepRectangle: View { let sweep: CardSweep var body: some View { - ZStack(alignment: .topLeading) { - Color.clear - if let rect = sweep.rectInShelf { - // Neutral, like Finder's: the cards' own selected look carries the result. - Rectangle() - .fill(Color.primary.opacity(0.08)) - .overlay(Rectangle().strokeBorder(Color.primary.opacity(0.28), lineWidth: 1)) - .frame(width: rect.width, height: rect.height) - .offset(x: rect.minX, y: rect.minY) - } + // Drawn as a path in the shelf's coordinates rather than as a sized, offset view: + // once the row has scrolled, the rectangle reaches far past the shelf's edge, and + // a view wider than its container gets re-centered by layout, which pulled the + // rectangle away from the pointer. + // Neutral, like Finder's: the cards' own selected look carries the result. + 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: it has to sit exactly under the pointer, and an animation picked + // up from elsewhere (a scroll, a selection change) would leave it trailing behind. + .transaction { $0.animation = nil } .allowsHitTesting(false) .accessibilityHidden(true) } } + +/// Hands the card row's `NSScrollView` to `CardSweep`. Sits in the row's content, where +/// AppKit's `enclosingScrollView` is the scroll view SwiftUI built for the row. +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() + // The scroll view is this view's ancestor only once the hierarchy is assembled. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.onFind?(self.enclosingScrollView) + } + } + } +} diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index 4bc5b10..cf1c5b6 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -634,6 +634,9 @@ 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 @@ -646,16 +649,6 @@ private struct ShelfItemsRow: View { viewModel.cardSweep.viewportFrame = $0 } .onPreferenceChange(CardFramesKey.self) { viewModel.cardSweep.cardFramesChanged($0) } - // While the sweep is held at an edge, step the row along a card at a time. - .task(id: viewModel.cardSweep.edgeScroll) { - while !Task.isCancelled, viewModel.cardSweep.edgeScroll != 0 { - if let target = viewModel.cardSweep.scrollTarget(in: viewModel.items.map(\.uuid)) { - let anchor: UnitPoint = viewModel.cardSweep.edgeScroll > 0 ? .trailing : .leading - withAnimation(.linear(duration: 0.18)) { proxy.scrollTo(target, anchor: anchor) } - } - try? await Task.sleep(for: .milliseconds(180)) - } - } .onChange(of: viewModel.selection.primary) { _, newPrimary in // A sweep moves the primary with the pointer; centering on it would // scroll the row out from under the rectangle. From 91a71f1eaa4a16bfadd0366b2a28b76fa4cc5171 Mon Sep 17 00:00:00 2001 From: devansh nigam Date: Fri, 2 Oct 2026 22:28:44 +0530 Subject: [PATCH 3/3] chore: trim CardSweep comments Signed-off-by: devansh nigam --- Copy/Shelf/CardSweep.swift | 75 ++++++++++++-------------------------- 1 file changed, 23 insertions(+), 52 deletions(-) diff --git a/Copy/Shelf/CardSweep.swift b/Copy/Shelf/CardSweep.swift index f5aa50a..6c133f6 100644 --- a/Copy/Shelf/CardSweep.swift +++ b/Copy/Shelf/CardSweep.swift @@ -1,8 +1,6 @@ import SwiftUI -/// Frames of the cards currently built in the card row, keyed by item uuid, in the row's -/// content space (`CardSweep.contentSpace`), which scrolls with the cards. The row is -/// lazy, so this only ever holds the cards on or near the screen. +/// 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]) { @@ -10,51 +8,39 @@ struct CardFramesKey: PreferenceKey { } } -/// The geometry of a drag-select: pressing in the shelf's empty space — in the card row or -/// up in the header beside the tabs — and sweeping a rectangle over cards selects them, as -/// in Finder. Pressing on a card or a pinboard tab is not a sweep; that drags it instead. -/// -/// This holds only the geometry — the rectangle, which cards it touches, and whether the -/// pointer is parked at an edge so the row should scroll. `ShelfViewModel` turns the cards -/// it touches into the selection. The pointer is reported in the shelf's `"shelfRoot"` -/// space; the rectangle is kept in the row's content space, so its starting corner stays -/// with the cards it started beside while the row scrolls under the pointer. +/// 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 { - /// Coordinate space of the scroll view's visible area. static let viewportSpace = "shelfRowViewport" - /// Coordinate space of the scrolling content; card frames and `rect` are measured here. static let contentSpace = "shelfRowContent" - /// The selection rectangle, in content space, while a sweep is in progress. + /// 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 card row's visible area, in the shelf's `"shelfRoot"` space. + /// The row's visible area, in shelf space. @ObservationIgnored var viewportFrame: CGRect = .zero - /// Where the sweep started, in content space. Non-nil while sweeping. + /// In content space. @ObservationIgnored private var start: CGPoint? - /// The pointer's latest position, in the shelf's space. + /// In shelf space. @ObservationIgnored private var pointer: CGPoint = .zero - /// The card row's scroll view, scrolled directly while the pointer is held at an edge. @ObservationIgnored weak var scrollView: NSScrollView? - /// Points to scroll per tick while the pointer is in an edge zone: negative toward - /// the leading edge, positive toward the trailing one, 0 when it is in neither. + /// Points per tick; negative scrolls toward the leading edge. @ObservationIgnored private var edgeScrollStep: CGFloat = 0 @ObservationIgnored private var edgeScrollTimer: Timer? - /// How close to the row's edge the pointer has to be for the row to scroll. private static let edgeScrollMargin: CGFloat = 44 - /// The fastest the row scrolls, in points per tick (60 ticks a second), reached when - /// the pointer is at or past the edge. It eases in from the start of the edge zone. private static let edgeScrollMaxStep: CGFloat = 16 var isActive: Bool { start != nil } - /// The row's lazy stack drops cards that scroll away, so during a sweep the frames are - /// accumulated: a card swept earlier stays selected after it leaves the screen. + /// 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 }) @@ -63,8 +49,7 @@ final class CardSweep { } } - /// Starts a sweep at `point` (shelf space). Returns false, starting nothing, when the - /// press is on a card or inside one of `draggables` (the pinboard tabs, in shelf space). + /// 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) }), @@ -81,8 +66,7 @@ final class CardSweep { refresh() } - /// The content moved inside the viewport (the row scrolled). Returns whether a sweep is - /// in progress, in which case the rectangle now reaches different cards. + /// The row scrolled. Returns whether a sweep is in progress. func contentMoved(to origin: CGPoint) -> Bool { contentOrigin = origin guard isActive else { return false } @@ -96,18 +80,16 @@ final class CardSweep { setEdgeScrollStep(0) } - /// The selection rectangle in the shelf's space, for drawing over the whole shelf. var rectInShelf: CGRect? { rect?.offsetBy(dx: contentOrigin.x + viewportFrame.minX, dy: contentOrigin.y + viewportFrame.minY) } - /// The cards the rectangle touches. var hits: Set { guard let rect else { return [] } return Set(cardFrames.filter { $0.value.intersects(rect) }.keys) } - /// Of the cards the rectangle touches, the one nearest the pointer. + /// The touched card nearest the pointer. var nearestHit: String? { guard let rect else { return nil } let x = contentPoint(pointer).x @@ -126,7 +108,7 @@ final class CardSweep { 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)) - // How far into an edge zone the pointer is: 0 at the zone's start, 1 at the edge. + // 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 { @@ -138,7 +120,6 @@ final class CardSweep { } } - /// Runs the edge-scroll timer only while there is something to scroll. private func setEdgeScrollStep(_ step: CGFloat) { edgeScrollStep = step if step == 0 { @@ -148,15 +129,13 @@ final class CardSweep { let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.edgeScrollTick() } } - // `.common` so it keeps firing while the mouse is held down in the drag. + // `.common` keeps it firing while the mouse is held down. RunLoop.main.add(timer, forMode: .common) edgeScrollTimer = timer } } - /// Scrolls the row a small step, the way a trackpad scroll would: continuous and - /// unanimated, so the rectangle and the selection follow it frame by frame (through - /// `contentMoved`) instead of chasing an animation. + /// 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 @@ -168,33 +147,26 @@ final class CardSweep { } } -/// The drag-select rectangle, drawn over the whole shelf (in its `"shelfRoot"` space) so a -/// sweep that starts in the header shows from there. Its own view so only it redraws as -/// the rectangle changes. +/// The drag-select rectangle, drawn over the whole shelf. struct CardSweepRectangle: View { let sweep: CardSweep var body: some View { - // Drawn as a path in the shelf's coordinates rather than as a sized, offset view: - // once the row has scrolled, the rectangle reaches far past the shelf's edge, and - // a view wider than its container gets re-centered by layout, which pulled the - // rectangle away from the pointer. - // Neutral, like Finder's: the cards' own selected look carries the result. + // 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: it has to sit exactly under the pointer, and an animation picked - // up from elsewhere (a scroll, a selection change) would leave it trailing behind. + // Never animated, or it trails the pointer. .transaction { $0.animation = nil } .allowsHitTesting(false) .accessibilityHidden(true) } } -/// Hands the card row's `NSScrollView` to `CardSweep`. Sits in the row's content, where -/// AppKit's `enclosingScrollView` is the scroll view SwiftUI built for the row. +/// Finds the card row's `NSScrollView` from inside its content. struct CardRowScrollViewFinder: NSViewRepresentable { let onFind: (NSScrollView?) -> Void @@ -213,7 +185,6 @@ struct CardRowScrollViewFinder: NSViewRepresentable { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - // The scroll view is this view's ancestor only once the hierarchy is assembled. DispatchQueue.main.async { [weak self] in guard let self else { return } self.onFind?(self.enclosingScrollView)