From be89eea2a08aa8ebefa740d97406b8b4e0d7ed9a Mon Sep 17 00:00:00 2001 From: Randy Teegarden Date: Fri, 14 Aug 2026 13:05:12 +0700 Subject: [PATCH 1/2] perf: reduce scroll update overhead --- Sources/OpenUsage/Support/MenuBarIcon.swift | 2 +- .../Support/MenuBarStripRenderer.swift | 4 +- .../OpenUsage/Support/ProviderIconShape.swift | 19 ++- .../Views/CustomizeProviderDetailView.swift | 20 ++- .../Views/CustomizeProviderListView.swift | 6 +- Sources/OpenUsage/Views/CustomizeView.swift | 8 +- Sources/OpenUsage/Views/ReorderGeometry.swift | 17 +- .../Views/WidgetGroupedListView.swift | 160 +++++++++++++----- 8 files changed, 166 insertions(+), 70 deletions(-) diff --git a/Sources/OpenUsage/Support/MenuBarIcon.swift b/Sources/OpenUsage/Support/MenuBarIcon.swift index f72991b32..b22acc36d 100644 --- a/Sources/OpenUsage/Support/MenuBarIcon.swift +++ b/Sources/OpenUsage/Support/MenuBarIcon.swift @@ -17,7 +17,7 @@ enum MenuBarIcon { let renderer = ImageRenderer( // Smaller inset than the provider default so the brand gauge keeps its prior menu-bar size // (its art already carries ~8% margin inside the source viewBox). - content: ProviderIconShape(pathData: mark.path, inset: 0.08) + content: ProviderIconShape(mark: mark, inset: 0.08) .fill(Color.black) .frame(width: side, height: side) ) diff --git a/Sources/OpenUsage/Support/MenuBarStripRenderer.swift b/Sources/OpenUsage/Support/MenuBarStripRenderer.swift index 23c9ace69..c24fe6f15 100644 --- a/Sources/OpenUsage/Support/MenuBarStripRenderer.swift +++ b/Sources/OpenUsage/Support/MenuBarStripRenderer.swift @@ -139,7 +139,7 @@ private struct MenuBarPrivacyLabel: View { // The same mark and inset as `MenuBarIcon` (the art carries its own margin), sized to the // strip's glyph box so the swap keeps the provider-glyph scale. if let mark = ProviderMarks.mark(for: "openusage") { - ProviderIconShape(pathData: mark.path, inset: 0.08) + ProviderIconShape(mark: mark, inset: 0.08) .fill(Color.black) .frame(width: 16, height: 16) } @@ -199,7 +199,7 @@ private struct MenuBarTextStrip: View { @ViewBuilder private func glyph(_ icon: IconSource) -> some View { if let mark = ProviderMarks.mark(for: icon.providerID) { - ProviderIconShape(pathData: mark.path, inset: 0.04) + ProviderIconShape(mark: mark, inset: 0.04) .fill(Color.black) .frame(width: Self.glyphSide, height: Self.glyphSide) } else { diff --git a/Sources/OpenUsage/Support/ProviderIconShape.swift b/Sources/OpenUsage/Support/ProviderIconShape.swift index 614689f20..b84ece517 100644 --- a/Sources/OpenUsage/Support/ProviderIconShape.swift +++ b/Sources/OpenUsage/Support/ProviderIconShape.swift @@ -22,7 +22,7 @@ struct ProviderIcon: View { var body: some View { if let mark = ProviderMarks.mark(for: source.providerID) { - ProviderIconShape(pathData: mark.path, inset: inset) + ProviderIconShape(mark: mark, inset: inset) .fill(Theme.iconGray) } else { Image(systemName: ProviderMarks.symbolFallback(for: source.providerID)) @@ -38,13 +38,13 @@ struct ProviderIcon: View { /// run edge-to-edge (Devin, Grok). Fitting the real path bounds gives every provider mark the same /// optical weight, then a single shared `inset` adds consistent breathing room so none touch the edge. struct ProviderIconShape: Shape { - let pathData: String + let mark: ProviderMark /// Fraction of the frame kept as margin on every side, so normalized marks have uniform padding. var inset: CGFloat = 0.14 func path(in rect: CGRect) -> Path { - let raw = SVGPath.parse(pathData) - let bounds = raw.cgPath.boundingBoxOfPath + let raw = mark.parsedPath + let bounds = mark.bounds guard bounds.width > 0, bounds.height > 0 else { return raw } let target = rect.insetBy(dx: rect.width * inset, dy: rect.height * inset) let scale = min(target.width / bounds.width, target.height / bounds.height) @@ -58,8 +58,17 @@ struct ProviderIconShape: Shape { /// A provider vector mark: the combined SVG path data. `ProviderIconShape` normalizes by the path's /// true bounding box, so the source `viewBox` isn't needed. -struct ProviderMark: Hashable { +struct ProviderMark { let path: String + let parsedPath: Path + let bounds: CGRect + + init(path: String) { + self.path = path + let parsedPath = SVGPath.parse(path) + self.parsedPath = parsedPath + self.bounds = parsedPath.cgPath.boundingBoxOfPath + } } /// Loads copied provider SVGs from the bundle and extracts their path data (cached). diff --git a/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift b/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift index aead1fcb3..f77854281 100644 --- a/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift +++ b/Sources/OpenUsage/Views/CustomizeProviderDetailView.swift @@ -20,7 +20,7 @@ struct CustomizeProviderDetailView: View { let providerID: String let reorderSpaceName: String @Binding var reorderLift: ReorderLift? - let rowFrames: [String: CGRect] + let frameStore: ReorderFrameStore @State private var activeMetricID: String? @AppStorage(DensitySetting.key) private var density = DensitySetting.regular @@ -134,7 +134,12 @@ struct CustomizeProviderDetailView: View { reorderLift?.location = value.location let divider = expandedDividerID(for: providerID) let ordered = reorderTargetIDs(for: providerID) - guard let target = reorderTarget(at: value.location, in: rowFrames, excluding: id, orderedIDs: ordered), + guard let target = reorderTarget( + at: value.location, + in: frameStore.frames, + excluding: id, + orderedIDs: ordered + ), let next = LayoutStore.reordered(ordered, dragged: id, target: target) else { return } withAnimation(Motion.spring) { _ = layout.applyMetricDividerOrder(next, dragged: id, dividerID: divider, in: providerID) @@ -147,9 +152,9 @@ struct CustomizeProviderDetailView: View { } /// The metric a drag started on, by hit-testing the drag start against the grip frames - /// ("grip:" entries in `rowFrames`). Nil when the drag didn't start on a grip. + /// ("grip:" entries in the frame store). Nil when the drag didn't start on a grip. private func metricID(at point: CGPoint) -> String? { - for (key, frame) in rowFrames { + for (key, frame) in frameStore.frames { guard key.hasPrefix("grip:"), frame.insetBy(dx: 0, dy: -2).contains(point) else { continue } return String(key.dropFirst("grip:".count)) } @@ -166,7 +171,12 @@ struct CustomizeProviderDetailView: View { private func makeLift(metricID: String, value: DragGesture.Value) -> ReorderLift? { let title = layout.customizeDetail(for: providerID)?.metrics.first { $0.id == metricID }?.title ?? "" - return ReorderLift.make(id: metricID, payload: .customizeMetric(title: title), value: value, frames: rowFrames) + return ReorderLift.make( + id: metricID, + payload: .customizeMetric(title: title), + value: value, + frames: frameStore.frames + ) } } diff --git a/Sources/OpenUsage/Views/CustomizeProviderListView.swift b/Sources/OpenUsage/Views/CustomizeProviderListView.swift index bbde1e48a..940d74eba 100644 --- a/Sources/OpenUsage/Views/CustomizeProviderListView.swift +++ b/Sources/OpenUsage/Views/CustomizeProviderListView.swift @@ -10,7 +10,7 @@ struct CustomizeProviderListView: View { @Environment(AppContainer.self) private var container let reorderSpaceName: String @Binding var reorderLift: ReorderLift? - let rowFrames: [String: CGRect] + let frameStore: ReorderFrameStore @State private var activeProviderID: String? @AppStorage(DensitySetting.key) private var density = DensitySetting.regular @@ -63,7 +63,7 @@ struct CustomizeProviderListView: View { reorderDragGesture( id: row.id, coordinateSpaceName: reorderSpaceName, - rowFrames: rowFrames, + frameStore: frameStore, active: $activeProviderID, lift: $reorderLift, makeLift: { makeProviderLift(for: row, value: $0) }, @@ -79,7 +79,7 @@ struct CustomizeProviderListView: View { id: row.id, payload: .customizeProviderRow(provider: row.provider, isEnabled: row.isEnabled, metricCount: row.metricCount), value: value, - frames: rowFrames + frames: frameStore.frames ) } } diff --git a/Sources/OpenUsage/Views/CustomizeView.swift b/Sources/OpenUsage/Views/CustomizeView.swift index 63c09e56c..704573d07 100644 --- a/Sources/OpenUsage/Views/CustomizeView.swift +++ b/Sources/OpenUsage/Views/CustomizeView.swift @@ -15,7 +15,7 @@ struct CustomizeView: View { let reorderSpaceName: String @Binding var reorderLift: ReorderLift? - @State private var rowFrames: [String: CGRect] = [:] + @State private var frameStore = ReorderFrameStore() var body: some View { PopoverScrollView { @@ -24,7 +24,7 @@ struct CustomizeView: View { .padding(.vertical, 12) .frame(maxWidth: .infinity) } - .onPreferenceChange(ReorderFramePreferenceKey.self) { rowFrames = $0 } + .onPreferenceChange(ReorderFramePreferenceKey.self) { frameStore.frames = $0 } // The transient star/denial pill floats above the Customize content — the same capsule style // as the dashboard's "Copied to clipboard" share pill. Green for a successful star/unstar, // orange for the per-provider cap denial. @@ -56,14 +56,14 @@ struct CustomizeView: View { providerID: id, reorderSpaceName: reorderSpaceName, reorderLift: $reorderLift, - rowFrames: rowFrames + frameStore: frameStore ) .transition(.move(edge: .trailing)) } else { CustomizeProviderListView( reorderSpaceName: reorderSpaceName, reorderLift: $reorderLift, - rowFrames: rowFrames + frameStore: frameStore ) .transition(.move(edge: .leading)) } diff --git a/Sources/OpenUsage/Views/ReorderGeometry.swift b/Sources/OpenUsage/Views/ReorderGeometry.swift index 167f6cbaa..e46d2314e 100644 --- a/Sources/OpenUsage/Views/ReorderGeometry.swift +++ b/Sources/OpenUsage/Views/ReorderGeometry.swift @@ -130,8 +130,19 @@ struct ReorderFramePreferenceKey: PreferenceKey { } } +/// Keeps continuously changing scroll geometry out of SwiftUI state. The preference updates on every +/// scroll frame; storing it in `@State` invalidates the whole widget list even though only drag gestures +/// need the latest values. +final class ReorderFrameStore { + var frames: [String: CGRect] = [:] +} + extension View { - func reorderFrame(id: String, in coordinateSpace: CoordinateSpace, yOutset: CGFloat = 0) -> some View { + func reorderFrame( + id: String, + in coordinateSpace: CoordinateSpace, + yOutset: CGFloat = 0 + ) -> some View { background( GeometryReader { proxy in Color.clear.preference( @@ -151,7 +162,7 @@ extension View { func reorderDragGesture( id: String, coordinateSpaceName: String, - rowFrames: [String: CGRect], + frameStore: ReorderFrameStore, active: Binding, lift: Binding, makeLift: @escaping (DragGesture.Value) -> ReorderLift?, @@ -167,7 +178,7 @@ func reorderDragGesture( lift.wrappedValue?.location = value.location guard let target = reorderTarget( at: value.location, - in: rowFrames, + in: frameStore.frames, excluding: id, orderedIDs: orderedIDs() ) else { return } diff --git a/Sources/OpenUsage/Views/WidgetGroupedListView.swift b/Sources/OpenUsage/Views/WidgetGroupedListView.swift index 3f88e585b..225ad2dc4 100644 --- a/Sources/OpenUsage/Views/WidgetGroupedListView.swift +++ b/Sources/OpenUsage/Views/WidgetGroupedListView.swift @@ -17,11 +17,15 @@ struct WidgetGroupedListView: View { let reorderSpaceName: String @Binding var reorderLift: ReorderLift? - @State private var rowFrames: [String: CGRect] = [:] - @State private var activeProviderID: String? - @State private var activeMetricID: String? + @State private var frameStore = ReorderFrameStore() + @State private var activeDrag: DashboardDragSource? @AppStorage(DensitySetting.key) private var density = DensitySetting.regular + private enum DashboardDragSource: Equatable { + case provider(String) + case metric(id: String, providerID: String) + } + var body: some View { // Provider-section spacing is noticeably wider than the in-card row rhythm (so groups // still read as groups); the exact step comes from the density setting. @@ -31,7 +35,8 @@ struct WidgetGroupedListView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .onPreferenceChange(ReorderFramePreferenceKey.self) { rowFrames = $0 } + .simultaneousGesture(dashboardDragGesture()) + .onPreferenceChange(ReorderFramePreferenceKey.self) { frameStore.frames = $0 } .animation(Motion.spring, value: layout.displayGroups.map(\.provider.id)) } @@ -40,7 +45,7 @@ struct WidgetGroupedListView: View { header(group) container(group) } - .opacity(activeProviderID == group.provider.id ? 0 : 1) + .opacity(reorderLift?.id == group.provider.id ? 0 : 1) .reorderFrame(id: group.provider.id, in: .named(reorderSpaceName)) } @@ -55,7 +60,7 @@ struct WidgetGroupedListView: View { ) // Keep the provider mark and hover-revealed copy control aligned with the card's content edges. .padding(.horizontal, 8) - .highPriorityGesture(providerDragGesture(for: group)) + .reorderFrame(id: providerSourceFrameID(for: group.provider.id), in: .named(reorderSpaceName)) .contextMenu { // Hides the whole provider section (the Customize provider list brings it back). Mirrors // the per-metric "Hide" but one level up, so the verb order reads the same on a header as a row. @@ -200,7 +205,10 @@ struct WidgetGroupedListView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .reorderFrame(id: expandedDividerID(for: providerID), in: .named(reorderSpaceName)) + .reorderFrame( + id: expandedDividerID(for: providerID), + in: .named(reorderSpaceName) + ) .accessibilityLabel(isExpanded ? "Show less" : "Show more") } @@ -224,7 +232,6 @@ struct WidgetGroupedListView: View { private func row(_ descriptor: WidgetDescriptor, data: WidgetData, in providerID: String, condensedTop: Bool) -> some View { - let isActive = activeMetricID == descriptor.id return WidgetRowView( data: data, onToggleResetDisplay: { dataStore.resetDisplayMode.toggle() }, @@ -232,8 +239,7 @@ struct WidgetGroupedListView: View { condensedTop: condensedTop ) .contentShape(Rectangle()) - .opacity(isActive ? 0 : 1) - .highPriorityGesture(metricDragGesture(for: descriptor, providerID: providerID)) + .opacity(reorderLift?.id == descriptor.id ? 0 : 1) .contextMenu { rowMenu(descriptor, providerID: providerID) } .reorderFrame(id: descriptor.id, in: .named(reorderSpaceName)) } @@ -276,44 +282,104 @@ struct WidgetGroupedListView: View { } } - private func providerDragGesture(for group: ProviderGroup) -> some Gesture { - reorderDragGesture( - id: group.provider.id, - coordinateSpaceName: reorderSpaceName, - rowFrames: rowFrames, - active: $activeProviderID, - lift: $reorderLift, - makeLift: { makeProviderLift(for: group, value: $0) }, - orderedIDs: { layout.displayGroups.map(\.provider.id) }, - reorder: { layout.reorderProvider(dragged: group.provider.id, target: $0) } - ) - } - - private func metricDragGesture(for descriptor: WidgetDescriptor, providerID: String) -> some Gesture { - reorderDragGesture( - id: descriptor.id, - coordinateSpaceName: reorderSpaceName, - rowFrames: rowFrames, - active: $activeMetricID, - lift: $reorderLift, - makeLift: { makeMetricLift(for: descriptor, value: $0) }, - orderedIDs: { metricTargetIDs(for: providerID) }, - reorder: { target in - let current = metricTargetIDs(for: providerID) - if current.contains(expandedDividerID(for: providerID)) { - guard let next = LayoutStore.reordered(current, dragged: descriptor.id, target: target) else { - return false + private func dashboardDragGesture() -> some Gesture { + DragGesture(minimumDistance: 4, coordinateSpace: .named(reorderSpaceName)) + .onChanged { value in + if activeDrag == nil { + activeDrag = dragSource(at: value.startLocation) + if let activeDrag { + reorderLift = makeLift(for: activeDrag, value: value) } - return layout.applyMetricDividerOrder( - next, - dragged: descriptor.id, - dividerID: expandedDividerID(for: providerID), - in: providerID - ) } - return layout.reorderMetric(dragged: descriptor.id, target: target, in: providerID) + guard let activeDrag else { return } + reorderLift?.location = value.location + + let draggedID: String + let orderedIDs: [String] + switch activeDrag { + case .provider(let providerID): + draggedID = providerID + orderedIDs = layout.displayGroups.map(\.provider.id) + case .metric(let id, let providerID): + draggedID = id + orderedIDs = metricTargetIDs(for: providerID) + } + + guard let target = reorderTarget( + at: value.location, + in: frameStore.frames, + excluding: draggedID, + orderedIDs: orderedIDs + ) else { return } + + var moved = false + withAnimation(Motion.spring) { + moved = reorder(activeDrag, target: target) + } + if moved { Haptics.snap() } } - ) + .onEnded { _ in + activeDrag = nil + reorderLift = nil + } + } + + private func dragSource(at point: CGPoint) -> DashboardDragSource? { + for group in layout.displayGroups { + let widgets = layout.isProviderExpanded(group.provider.id) ? group.widgets : group.alwaysShownWidgets + for widget in widgets { + guard let descriptor = layout.descriptor(for: widget), + frameStore.frames[descriptor.id]?.insetBy(dx: 0, dy: -2).contains(point) == true + else { continue } + return .metric(id: descriptor.id, providerID: group.provider.id) + } + } + for group in layout.displayGroups + where frameStore.frames[providerSourceFrameID(for: group.provider.id)]?.insetBy(dx: 0, dy: -2).contains(point) == true { + return .provider(group.provider.id) + } + return nil + } + + private func makeLift(for source: DashboardDragSource, value: DragGesture.Value) -> ReorderLift? { + switch source { + case .provider(let providerID): + guard let group = layout.displayGroups.first(where: { $0.provider.id == providerID }) else { return nil } + return makeProviderLift(for: group, value: value) + case .metric(let id, let providerID): + guard let descriptor = metricDescriptor(id: id, providerID: providerID) else { return nil } + return makeMetricLift(for: descriptor, value: value) + } + } + + private func reorder(_ source: DashboardDragSource, target: String) -> Bool { + switch source { + case .provider(let providerID): + return layout.reorderProvider(dragged: providerID, target: target) + case .metric(let id, let providerID): + let current = metricTargetIDs(for: providerID) + if current.contains(expandedDividerID(for: providerID)) { + guard let next = LayoutStore.reordered(current, dragged: id, target: target) else { return false } + return layout.applyMetricDividerOrder( + next, + dragged: id, + dividerID: expandedDividerID(for: providerID), + in: providerID + ) + } + return layout.reorderMetric(dragged: id, target: target, in: providerID) + } + } + + private func metricDescriptor(id: String, providerID: String) -> WidgetDescriptor? { + guard let group = layout.displayGroups.first(where: { $0.provider.id == providerID }), + let widget = group.widgets.first(where: { layout.descriptor(for: $0)?.id == id }) + else { return nil } + return layout.descriptor(for: widget) + } + + private func providerSourceFrameID(for providerID: String) -> String { + "\(providerID)::dashboard-provider-source" } private func metricTargetIDs(for providerID: String) -> [String] { @@ -346,7 +412,7 @@ struct WidgetGroupedListView: View { rows: rows ), value: value, - frames: rowFrames + frames: frameStore.frames ) } @@ -355,7 +421,7 @@ struct WidgetGroupedListView: View { id: descriptor.id, payload: .dashboardMetric(data: dataStore.data(for: descriptor)), value: value, - frames: rowFrames + frames: frameStore.frames ) } } From 5068cc0ab4e517868e99df2f50e676a05a968490 Mon Sep 17 00:00:00 2001 From: David Arutyunyan Date: Sat, 15 Aug 2026 10:58:51 +0700 Subject: [PATCH 2/2] perf: avoid dashboard-wide drag gesture --- .../Views/WidgetGroupedListView.swift | 146 +++++------------- 1 file changed, 41 insertions(+), 105 deletions(-) diff --git a/Sources/OpenUsage/Views/WidgetGroupedListView.swift b/Sources/OpenUsage/Views/WidgetGroupedListView.swift index 225ad2dc4..0fe233d3f 100644 --- a/Sources/OpenUsage/Views/WidgetGroupedListView.swift +++ b/Sources/OpenUsage/Views/WidgetGroupedListView.swift @@ -18,14 +18,10 @@ struct WidgetGroupedListView: View { @Binding var reorderLift: ReorderLift? @State private var frameStore = ReorderFrameStore() - @State private var activeDrag: DashboardDragSource? + @State private var activeProviderID: String? + @State private var activeMetricID: String? @AppStorage(DensitySetting.key) private var density = DensitySetting.regular - private enum DashboardDragSource: Equatable { - case provider(String) - case metric(id: String, providerID: String) - } - var body: some View { // Provider-section spacing is noticeably wider than the in-card row rhythm (so groups // still read as groups); the exact step comes from the density setting. @@ -35,7 +31,6 @@ struct WidgetGroupedListView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .simultaneousGesture(dashboardDragGesture()) .onPreferenceChange(ReorderFramePreferenceKey.self) { frameStore.frames = $0 } .animation(Motion.spring, value: layout.displayGroups.map(\.provider.id)) } @@ -45,7 +40,7 @@ struct WidgetGroupedListView: View { header(group) container(group) } - .opacity(reorderLift?.id == group.provider.id ? 0 : 1) + .opacity(activeProviderID == group.provider.id ? 0 : 1) .reorderFrame(id: group.provider.id, in: .named(reorderSpaceName)) } @@ -60,7 +55,7 @@ struct WidgetGroupedListView: View { ) // Keep the provider mark and hover-revealed copy control aligned with the card's content edges. .padding(.horizontal, 8) - .reorderFrame(id: providerSourceFrameID(for: group.provider.id), in: .named(reorderSpaceName)) + .highPriorityGesture(providerDragGesture(for: group)) .contextMenu { // Hides the whole provider section (the Customize provider list brings it back). Mirrors // the per-metric "Hide" but one level up, so the verb order reads the same on a header as a row. @@ -239,7 +234,8 @@ struct WidgetGroupedListView: View { condensedTop: condensedTop ) .contentShape(Rectangle()) - .opacity(reorderLift?.id == descriptor.id ? 0 : 1) + .opacity(activeMetricID == descriptor.id ? 0 : 1) + .highPriorityGesture(metricDragGesture(for: descriptor, providerID: providerID)) .contextMenu { rowMenu(descriptor, providerID: providerID) } .reorderFrame(id: descriptor.id, in: .named(reorderSpaceName)) } @@ -282,104 +278,44 @@ struct WidgetGroupedListView: View { } } - private func dashboardDragGesture() -> some Gesture { - DragGesture(minimumDistance: 4, coordinateSpace: .named(reorderSpaceName)) - .onChanged { value in - if activeDrag == nil { - activeDrag = dragSource(at: value.startLocation) - if let activeDrag { - reorderLift = makeLift(for: activeDrag, value: value) - } - } - guard let activeDrag else { return } - reorderLift?.location = value.location - - let draggedID: String - let orderedIDs: [String] - switch activeDrag { - case .provider(let providerID): - draggedID = providerID - orderedIDs = layout.displayGroups.map(\.provider.id) - case .metric(let id, let providerID): - draggedID = id - orderedIDs = metricTargetIDs(for: providerID) - } - - guard let target = reorderTarget( - at: value.location, - in: frameStore.frames, - excluding: draggedID, - orderedIDs: orderedIDs - ) else { return } - - var moved = false - withAnimation(Motion.spring) { - moved = reorder(activeDrag, target: target) - } - if moved { Haptics.snap() } - } - .onEnded { _ in - activeDrag = nil - reorderLift = nil - } - } - - private func dragSource(at point: CGPoint) -> DashboardDragSource? { - for group in layout.displayGroups { - let widgets = layout.isProviderExpanded(group.provider.id) ? group.widgets : group.alwaysShownWidgets - for widget in widgets { - guard let descriptor = layout.descriptor(for: widget), - frameStore.frames[descriptor.id]?.insetBy(dx: 0, dy: -2).contains(point) == true - else { continue } - return .metric(id: descriptor.id, providerID: group.provider.id) - } - } - for group in layout.displayGroups - where frameStore.frames[providerSourceFrameID(for: group.provider.id)]?.insetBy(dx: 0, dy: -2).contains(point) == true { - return .provider(group.provider.id) - } - return nil - } - - private func makeLift(for source: DashboardDragSource, value: DragGesture.Value) -> ReorderLift? { - switch source { - case .provider(let providerID): - guard let group = layout.displayGroups.first(where: { $0.provider.id == providerID }) else { return nil } - return makeProviderLift(for: group, value: value) - case .metric(let id, let providerID): - guard let descriptor = metricDescriptor(id: id, providerID: providerID) else { return nil } - return makeMetricLift(for: descriptor, value: value) - } + private func providerDragGesture(for group: ProviderGroup) -> some Gesture { + reorderDragGesture( + id: group.provider.id, + coordinateSpaceName: reorderSpaceName, + frameStore: frameStore, + active: $activeProviderID, + lift: $reorderLift, + makeLift: { makeProviderLift(for: group, value: $0) }, + orderedIDs: { layout.displayGroups.map(\.provider.id) }, + reorder: { layout.reorderProvider(dragged: group.provider.id, target: $0) } + ) } - private func reorder(_ source: DashboardDragSource, target: String) -> Bool { - switch source { - case .provider(let providerID): - return layout.reorderProvider(dragged: providerID, target: target) - case .metric(let id, let providerID): - let current = metricTargetIDs(for: providerID) - if current.contains(expandedDividerID(for: providerID)) { - guard let next = LayoutStore.reordered(current, dragged: id, target: target) else { return false } - return layout.applyMetricDividerOrder( - next, - dragged: id, - dividerID: expandedDividerID(for: providerID), - in: providerID - ) + private func metricDragGesture(for descriptor: WidgetDescriptor, providerID: String) -> some Gesture { + reorderDragGesture( + id: descriptor.id, + coordinateSpaceName: reorderSpaceName, + frameStore: frameStore, + active: $activeMetricID, + lift: $reorderLift, + makeLift: { makeMetricLift(for: descriptor, value: $0) }, + orderedIDs: { metricTargetIDs(for: providerID) }, + reorder: { target in + let current = metricTargetIDs(for: providerID) + if current.contains(expandedDividerID(for: providerID)) { + guard let next = LayoutStore.reordered(current, dragged: descriptor.id, target: target) else { + return false + } + return layout.applyMetricDividerOrder( + next, + dragged: descriptor.id, + dividerID: expandedDividerID(for: providerID), + in: providerID + ) + } + return layout.reorderMetric(dragged: descriptor.id, target: target, in: providerID) } - return layout.reorderMetric(dragged: id, target: target, in: providerID) - } - } - - private func metricDescriptor(id: String, providerID: String) -> WidgetDescriptor? { - guard let group = layout.displayGroups.first(where: { $0.provider.id == providerID }), - let widget = group.widgets.first(where: { layout.descriptor(for: $0)?.id == id }) - else { return nil } - return layout.descriptor(for: widget) - } - - private func providerSourceFrameID(for providerID: String) -> String { - "\(providerID)::dashboard-provider-source" + ) } private func metricTargetIDs(for providerID: String) -> [String] {