From 7a9ced6a0a63c6849c767ee131d22fb3876a4f23 Mon Sep 17 00:00:00 2001 From: Andres Date: Thu, 23 Jul 2026 09:06:21 -0400 Subject: [PATCH 1/4] Fix deterministic sidebar space gestures --- .../Sidebar/SidebarSpaceCreationPull.swift | 361 +++++ .../Sidebar/SidebarSpacePagerInput.swift | 469 ++++++ .../Sidebar/SidebarSpacePagerModels.swift | 295 ++++ .../Sidebar/SidebarSpacePagerPolicy.swift | 559 +++++++ .../Views/Sidebar/SidebarView.swift | 1307 +++-------------- .../SidebarSpacePagerGestureTests.swift | 264 ++++ .../SidebarSpacePagerSelectionTests.swift | 324 +++- 7 files changed, 2423 insertions(+), 1156 deletions(-) create mode 100644 Sources/MeridianCore/Views/Sidebar/SidebarSpaceCreationPull.swift create mode 100644 Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerInput.swift create mode 100644 Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerModels.swift create mode 100644 Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift create mode 100644 Tests/MeridianBrowserTests/SidebarSpacePagerGestureTests.swift diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarSpaceCreationPull.swift b/Sources/MeridianCore/Views/Sidebar/SidebarSpaceCreationPull.swift new file mode 100644 index 0000000..565d3e7 --- /dev/null +++ b/Sources/MeridianCore/Views/Sidebar/SidebarSpaceCreationPull.swift @@ -0,0 +1,361 @@ +import SwiftUI + +struct SidebarSpaceCreationPullEligibility { + static func canBegin( + creationIsAvailable: Bool, + scrollIsIdle: Bool, + currentOffsetX: CGFloat, + lastPageOffsetX: CGFloat + ) -> Bool { + guard creationIsAvailable, + scrollIsIdle, + currentOffsetX.isFinite, + lastPageOffsetX.isFinite else { + return false + } + + return abs(currentOffsetX - lastPageOffsetX) + <= SidebarSpacePagerMetrics.creationSettledOffsetTolerance + } +} + +enum SidebarSpaceCreationPullDisposition: Equatable, Sendable { + case pulling + case cancelled +} + +struct SidebarSpaceCreationPullUpdate: Equatable, Sendable { + let disposition: SidebarSpaceCreationPullDisposition + let displayedDistance: CGFloat + let progress: CGFloat +} + +enum SidebarSpaceCreationPullReleaseOutcome: Equatable, Sendable { + case none + case cancel + case create +} + +struct SidebarSpaceCreationPullState: Equatable, Sendable { + private(set) var unresistedDistance: CGFloat = 0 + private(set) var displayedDistance: CGFloat = 0 + private(set) var progress: CGFloat = 0 + private var isActive = false + + mutating func begin( + initialForwardDistance: CGFloat, + pageWidth: CGFloat + ) -> SidebarSpaceCreationPullUpdate? { + guard initialForwardDistance.isFinite, + initialForwardDistance > 0 else { + return nil + } + + isActive = true + unresistedDistance = 0 + return applyForwardDistance(initialForwardDistance, pageWidth: pageWidth) + } + + mutating func consumeAdjustedDelta( + _ adjustedDeltaX: CGFloat, + pageWidth: CGFloat + ) -> SidebarSpaceCreationPullUpdate? { + guard isActive, + adjustedDeltaX.isFinite else { + return nil + } + + let proposedDistance = unresistedDistance + adjustedDeltaX + guard proposedDistance > 0 else { + cancel() + return SidebarSpaceCreationPullUpdate( + disposition: .cancelled, + displayedDistance: 0, + progress: 0 + ) + } + + return applyForwardDistance(proposedDistance, pageWidth: pageWidth) + } + + mutating func release() -> SidebarSpaceCreationPullReleaseOutcome { + guard isActive else { + return .none + } + + isActive = false + return progress >= 1 ? .create : .cancel + } + + mutating func cancel() { + isActive = false + unresistedDistance = 0 + displayedDistance = 0 + progress = 0 + } + + static func resistedDistance(for distance: CGFloat, pageWidth: CGFloat) -> CGFloat { + guard distance.isFinite, + distance > 0, + pageWidth.isFinite, + pageWidth > 0 else { + return 0 + } + + let coefficient = SidebarSpacePagerMetrics.creationPullResistance + let resistedDistance = (distance * pageWidth * coefficient) + / (pageWidth + coefficient * distance) + return min(resistedDistance, SidebarSpacePagerMetrics.creationRailMaximumWidth) + } + + private mutating func applyForwardDistance( + _ distance: CGFloat, + pageWidth: CGFloat + ) -> SidebarSpaceCreationPullUpdate { + unresistedDistance = distance + displayedDistance = Self.resistedDistance(for: distance, pageWidth: pageWidth) + progress = min( + max(distance / SidebarSpacePagerMetrics.creationPullThreshold, 0), + 1 + ) + return SidebarSpaceCreationPullUpdate( + disposition: .pulling, + displayedDistance: displayedDistance, + progress: progress + ) + } +} + +enum SidebarSpaceCreationGestureMode: Equatable, Sendable { + case disabled + case pendingDirection + case pulling + case pagingBackward + case cancelled +} + +enum SidebarSpaceCreationGestureRouting: Equatable, Sendable { + case pending + case pagingBackward + case pulling(SidebarSpaceCreationPullUpdate) + case cancelled +} + +/// Owns only the pull-past-the-last-space state machine. AppKit event routing, +/// pager snapping, and SwiftUI presentation side effects remain outside it. +struct SidebarSpaceCreationGestureSession: Equatable, Sendable { + private(set) var mode = SidebarSpaceCreationGestureMode.disabled + private(set) var pullState = SidebarSpaceCreationPullState() + private var pendingAdjustedDisplacementX: CGFloat = 0 + + var isPendingDirection: Bool { mode == .pendingDirection } + var isPulling: Bool { mode == .pulling } + var isCancelled: Bool { mode == .cancelled } + var routesBoundaryInput: Bool { mode != .disabled } + + mutating func begin(canPullForward: Bool) { + reset() + guard canPullForward else { + return + } + + mode = .pendingDirection + } + + mutating func routeAdjustedDelta( + _ adjustedDeltaX: CGFloat, + pageWidth: CGFloat, + minimumDisplacement: CGFloat = SidebarSpacePagerMetrics.creationDirectionInferenceDistance + ) -> SidebarSpaceCreationGestureRouting { + switch mode { + case .disabled: + return .pagingBackward + case .pagingBackward: + return .pagingBackward + case .cancelled: + return .cancelled + case .pulling: + guard let update = pullState.consumeAdjustedDelta( + adjustedDeltaX, + pageWidth: pageWidth + ) else { + return .cancelled + } + if update.disposition == .cancelled { + mode = .cancelled + return .cancelled + } + return .pulling(update) + case .pendingDirection: + guard adjustedDeltaX.isFinite else { + return .pending + } + pendingAdjustedDisplacementX += adjustedDeltaX + } + + guard let intent = SidebarSpacePagerHorizontalIntent.intent( + forAdjustedDisplacement: pendingAdjustedDisplacementX, + minimumDisplacement: minimumDisplacement + ) else { + return .pending + } + + switch intent { + case .backward: + mode = .pagingBackward + clearPendingDirection() + return .pagingBackward + case .forward: + guard let update = pullState.begin( + initialForwardDistance: pendingAdjustedDisplacementX, + pageWidth: pageWidth + ) else { + return .pending + } + + mode = .pulling + clearPendingDirection() + return .pulling(update) + } + } + + mutating func finishPull(cancelled: Bool) -> SidebarSpaceCreationPullReleaseOutcome { + switch mode { + case .pulling: + return cancelled ? cancelPull() : pullState.release() + case .cancelled: + pullState.cancel() + return .cancel + case .disabled, .pendingDirection, .pagingBackward: + pullState.cancel() + return .none + } + } + + mutating func end() { + mode = .disabled + clearPendingDirection() + } + + mutating func reset() { + mode = .disabled + pullState.cancel() + clearPendingDirection() + } + + private mutating func cancelPull() -> SidebarSpaceCreationPullReleaseOutcome { + pullState.cancel() + return .cancel + } + + private mutating func clearPendingDirection() { + pendingAdjustedDisplacementX = 0 + } +} + +struct SidebarSpaceCreationPullPresentation: Equatable, Sendable { + var displayedDistance: CGFloat = 0 + var progress: CGFloat = 0 + + var isArmed: Bool { + progress >= 1 + } +} + +@MainActor +final class SidebarSpaceCreationPullController: ObservableObject { + @Published private(set) var presentation = SidebarSpaceCreationPullPresentation() + + func update(displayedDistance: CGFloat, progress: CGFloat) { + let presentation = SidebarSpaceCreationPullPresentation( + displayedDistance: max(displayedDistance, 0), + progress: min(max(progress, 0), 1) + ) + guard self.presentation != presentation else { + return + } + self.presentation = presentation + } + + func returnToRest(animated: Bool) { + let update = { + self.presentation = SidebarSpaceCreationPullPresentation() + } + if animated { + withAnimation(SidebarSpacePagerMetrics.creationReturnAnimation, update) + } else { + update() + } + } +} + +private struct SidebarSpaceCreationRailView: View { + let presentation: SidebarSpaceCreationPullPresentation + let foregroundColor: Color + + var body: some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + + ZStack { + Circle() + .stroke(foregroundColor.opacity(0.20), lineWidth: 2.2) + + Circle() + .trim(from: 0, to: presentation.progress) + .stroke( + foregroundColor.opacity(presentation.isArmed ? 1 : 0.84), + style: StrokeStyle(lineWidth: 2.4, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + + Image(systemName: "plus") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(foregroundColor.opacity(presentation.isArmed ? 1 : 0.78)) + } + .frame( + width: SidebarSpacePagerMetrics.creationAffordanceDiameter, + height: SidebarSpacePagerMetrics.creationAffordanceDiameter + ) + + Spacer() + .frame(width: SidebarSpacePagerMetrics.creationRailTrailingPadding) + } + .background { + LinearGradient( + colors: [foregroundColor.opacity(0.02), foregroundColor.opacity(0.09)], + startPoint: .leading, + endPoint: .trailing + ) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +struct SidebarSpaceCreationPullPresentationModifier: ViewModifier { + @ObservedObject var controller: SidebarSpaceCreationPullController + let foregroundColor: Color + + func body(content: Content) -> some View { + let presentation = controller.presentation + let exposedWidth = min( + presentation.displayedDistance, + SidebarSpacePagerMetrics.creationRailMaximumWidth + ) + + ZStack(alignment: .trailing) { + SidebarSpaceCreationRailView( + presentation: presentation, + foregroundColor: foregroundColor + ) + .frame(width: SidebarSpacePagerMetrics.creationRailMaximumWidth) + .frame(width: exposedWidth, alignment: .trailing) + .clipped() + + content + .offset(x: -presentation.displayedDistance) + } + .clipped() + } +} diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerInput.swift b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerInput.swift new file mode 100644 index 0000000..c9e13df --- /dev/null +++ b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerInput.swift @@ -0,0 +1,469 @@ +import AppKit +import SwiftUI + +struct SidebarSpacePagerScrollInputScalingInstaller: NSViewRepresentable { + let sensitivity: CGFloat + let pageWidth: CGFloat + let pageCount: Int + let creationIsAvailable: Bool + let geometryTracker: SidebarSpacePagerGeometryTracker + let creationPullController: SidebarSpaceCreationPullController + let createSpace: () -> Void + + func makeNSView(context: Context) -> SidebarSpacePagerScrollInputScalingView { + let view = SidebarSpacePagerScrollInputScalingView() + view.geometryTracker = geometryTracker + view.creationPullController = creationPullController + view.createSpace = createSpace + view.sensitivity = sensitivity + view.pageWidth = pageWidth + view.pageCount = pageCount + view.creationIsAvailable = creationIsAvailable + return view + } + + func updateNSView(_ nsView: SidebarSpacePagerScrollInputScalingView, context: Context) { + nsView.geometryTracker = geometryTracker + nsView.creationPullController = creationPullController + nsView.createSpace = createSpace + nsView.sensitivity = sensitivity + nsView.pageWidth = pageWidth + nsView.pageCount = pageCount + nsView.creationIsAvailable = creationIsAvailable + nsView.installIfNeeded() + } + + static func dismantleNSView( + _ nsView: SidebarSpacePagerScrollInputScalingView, + coordinator: () + ) { + nsView.tearDown() + } +} + +enum SidebarSpacePagerPhysicalGestureEndReason: Equatable, Sendable { + case physicalEnded + case physicalCancelled + case momentumBegan + case unphasedInput +} + +/// Tracks physical finger ownership with monotonically increasing identities so +/// a deferred completion from one gesture cannot terminate a newer gesture. +struct SidebarSpacePagerPhysicalGestureLifecycle: Equatable, Sendable { + private(set) var activeGestureID: UInt64? + private var nextGestureID: UInt64 = 0 + + var isActive: Bool { + activeGestureID != nil + } + + mutating func begin() -> UInt64 { + nextGestureID &+= 1 + activeGestureID = nextGestureID + return nextGestureID + } + + func shouldFinish(for reason: SidebarSpacePagerPhysicalGestureEndReason) -> Bool { + isActive + } + + @discardableResult + mutating func finish(for reason: SidebarSpacePagerPhysicalGestureEndReason) -> UInt64? { + guard shouldFinish(for: reason), + let activeGestureID else { + return nil + } + self.activeGestureID = nil + return activeGestureID + } + + @discardableResult + mutating func reset() -> UInt64? { + defer { activeGestureID = nil } + return activeGestureID + } +} + + +final class SidebarSpacePagerScrollInputScalingView: NSView { + weak var geometryTracker: SidebarSpacePagerGeometryTracker? + weak var creationPullController: SidebarSpaceCreationPullController? + var createSpace: (() -> Void)? + var sensitivity: CGFloat = 1 + var pageWidth: CGFloat = 0 { + didSet { + guard oldValue > 0, + abs(oldValue - pageWidth) > 0.5 else { + return + } + + ignoreRemainderOfPhysicalGesture() + } + } + var pageCount = 0 { + didSet { + guard oldValue > 0, + oldValue != pageCount else { + return + } + cancelCreationInteraction(animated: false) + } + } + var creationIsAvailable = false { + didSet { + if !creationIsAvailable, !gestureLifecycle.isActive { + cancelCreationInteraction(animated: false) + } + } + } + + private weak var configuredScrollView: NSScrollView? + private var eventMonitor: Any? + private var deferredInstallationIsScheduled = false + private var gestureLifecycle = SidebarSpacePagerPhysicalGestureLifecycle() + private var gestureGate = SidebarSpacePagerPhysicalGestureGate() + private var inputScalingState = SidebarSpacePagerScrollInputScalingState() + private var horizontalGestureAccumulator = + SidebarSpacePagerHorizontalGestureAccumulator() + private var creationSession = SidebarSpaceCreationGestureSession() + private var suppressesCreationMomentum = false + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + installIfNeeded() + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + installIfNeeded() + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + super.viewWillMove(toWindow: newWindow) + if newWindow == nil { + tearDown() + } + } + + override func layout() { + super.layout() + installIfNeeded() + } + + deinit { + MainActor.assumeIsolated { + tearDown() + } + } + + func installIfNeeded() { + guard window != nil else { + return + } + + let candidateScrollView = nearestScrollView + if let configuredScrollView, + configuredScrollView === candidateScrollView { + installEventMonitorIfNeeded() + return + } + + let replacedConfiguredScrollView = configuredScrollView != nil + clearConfiguredScrollView() + if replacedConfiguredScrollView { + ignoreRemainderOfPhysicalGesture() + } else { + resetLocalGestureState() + } + + guard let candidateScrollView else { + scheduleDeferredInstallation() + return + } + + configuredScrollView = candidateScrollView + installEventMonitorIfNeeded() + } + + func tearDown() { + clearConfiguredScrollView() + removeEventMonitor() + resetLocalGestureState() + deferredInstallationIsScheduled = false + } + + private var nearestScrollView: NSScrollView? { + var candidate: NSView? = self + while let current = candidate { + if let scrollView = current as? NSScrollView { + return scrollView + } + candidate = current.superview + } + + return enclosingScrollView + } + + private func scheduleDeferredInstallation() { + guard !deferredInstallationIsScheduled else { + return + } + + deferredInstallationIsScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + + self.deferredInstallationIsScheduled = false + self.installIfNeeded() + } + } + + private func installEventMonitorIfNeeded() { + guard eventMonitor == nil else { + return + } + + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self else { + return event + } + return self.eventForPagerInput(from: event) + } + } + + private func removeEventMonitor() { + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + self.eventMonitor = nil + } + } + + private func clearConfiguredScrollView() { + configuredScrollView = nil + } + + private func eventForPagerInput(from event: NSEvent) -> NSEvent? { + guard let scrollView = configuredScrollView, + event.window === scrollView.window else { + return event + } + + if !event.momentumPhase.isEmpty { + if suppressesCreationMomentum { + if event.momentumPhase.contains(.ended) || event.momentumPhase.contains(.cancelled) { + suppressesCreationMomentum = false + } + return nil + } + finishPhysicalGesture(cancelled: false, reason: .momentumBegan) + return event + } + + let phase = event.phase + guard !phase.isEmpty else { + finishPhysicalGesture(cancelled: false, reason: .unphasedInput) + return event + } + guard event.hasPreciseScrollingDeltas else { + finishPhysicalGesture(cancelled: true, reason: .physicalCancelled) + return event + } + + if phase.contains(.mayBegin) || phase.contains(.began) { + beginGestureIfInsidePager(event, scrollView: scrollView) + } + + if phase.contains(.ended) || phase.contains(.cancelled) { + let cancelled = phase.contains(.cancelled) + finishPhysicalGesture( + cancelled: cancelled, + reason: cancelled ? .physicalCancelled : .physicalEnded + ) + return event + } + + guard !gestureGate.ignoresChangedEvents else { + return event + } + + guard phase.contains(.began) || phase.contains(.changed) else { + return event + } + + if !gestureLifecycle.isActive { + guard phase.contains(.changed), + eventIsInsidePager(event, scrollView: scrollView) else { + return event + } + + beginGesture() + } + + guard gestureLifecycle.isActive, + let horizontalInput = SidebarSpacePagerHorizontalInputSample( + scrollingDeltaX: event.scrollingDeltaX + ), + let scaledEvent = inputScalingState.scaledEvent( + from: event, + sensitivity: sensitivity + ) else { + return event + } + + horizontalGestureAccumulator.append(horizontalInput) + geometryTracker?.adjustedGestureDisplacementX = + horizontalGestureAccumulator.cumulativeAdjustedDisplacementX + + if creationSession.routesBoundaryInput { + let wasCancelled = creationSession.isCancelled + switch creationSession.routeAdjustedDelta( + horizontalInput.adjustedDisplacementX, + pageWidth: pageWidth + ) { + case .pending, .pagingBackward: + return scaledEvent + case .pulling(let update): + geometryTracker?.setCreationPullLocksLastPage(true) + creationPullController?.update( + displayedDistance: update.displayedDistance, + progress: update.progress + ) + return nil + case .cancelled: + if !wasCancelled { + creationPullController?.returnToRest(animated: true) + } + // Once a creation pull reverses past its origin, this physical + // gesture is cancelled and remains locked to the last page. + return nil + } + } + + return scaledEvent + } + + private func beginGestureIfInsidePager(_ event: NSEvent, scrollView: NSScrollView) { + guard eventIsInsidePager(event, scrollView: scrollView) else { + resetLocalGestureState() + return + } + + if !gestureLifecycle.isActive { + beginGesture() + } + } + + private func beginGesture() { + gestureGate.begin() + let gestureID = gestureLifecycle.begin() + inputScalingState.reset() + horizontalGestureAccumulator.reset() + suppressesCreationMomentum = false + creationPullController?.returnToRest(animated: false) + + let isSettledOnLastPage: Bool + let currentOffsetX = configuredScrollView?.contentView.bounds.origin.x + if let currentOffsetX, + let lastPageOffsetX { + isSettledOnLastPage = SidebarSpaceCreationPullEligibility.canBegin( + creationIsAvailable: creationIsAvailable, + scrollIsIdle: geometryTracker?.scrollIsIdle == true, + currentOffsetX: currentOffsetX, + lastPageOffsetX: lastPageOffsetX + ) + } else { + isSettledOnLastPage = false + } + creationSession.begin(canPullForward: isSettledOnLastPage) + + // AppKit exposes a new physical trackpad gesture even when SwiftUI keeps + // the scroll phase in `decelerating`. Capture that boundary directly so + // a rapid follow-up swipe gets its own origin and advances from the + // previous gesture's resolved destination. + geometryTracker?.beginPhysicalGesture(id: gestureID) + } + + private func eventIsInsidePager(_ event: NSEvent, scrollView: NSScrollView) -> Bool { + let localPoint = scrollView.convert(event.locationInWindow, from: nil) + return scrollView.bounds.contains(localPoint) + } + + private func resetLocalGestureState() { + cancelCreationInteraction(animated: false) + suppressesCreationMomentum = false + gestureGate.end() + clearLocalGestureState() + } + + private func finishPhysicalGesture( + cancelled: Bool, + reason: SidebarSpacePagerPhysicalGestureEndReason + ) { + guard let gestureID = gestureLifecycle.finish(for: reason) else { + return + } + + let consumedCreationInput = creationSession.isPulling || creationSession.isCancelled + if consumedCreationInput { + let outcome = creationSession.finishPull(cancelled: cancelled) + suppressesCreationMomentum = true + switch outcome { + case .none: + creationPullController?.returnToRest(animated: false) + case .cancel: + creationPullController?.returnToRest(animated: true) + case .create: + let createSpace = createSpace + DispatchQueue.main.async { [weak creationPullController] in + createSpace?() + creationPullController?.returnToRest(animated: true) + } + } + } else { + creationSession.reset() + creationPullController?.returnToRest(animated: false) + geometryTracker?.setCreationPullLocksLastPage(false) + } + + creationSession.end() + gestureGate.end() + inputScalingState.reset() + horizontalGestureAccumulator.reset() + + let geometryTracker = geometryTracker + DispatchQueue.main.async { [weak geometryTracker] in + geometryTracker?.endPhysicalGesture(id: gestureID) + } + } + + private var lastPageOffsetX: CGFloat? { + guard pageCount > 0, + pageWidth.isFinite, + pageWidth > 0 else { + return nil + } + return CGFloat(pageCount - 1) * pageWidth + } + + private func cancelCreationInteraction(animated: Bool) { + creationSession.reset() + geometryTracker?.setCreationPullLocksLastPage(false) + creationPullController?.returnToRest(animated: animated) + } + + private func ignoreRemainderOfPhysicalGesture() { + gestureGate.ignoreChangedEventsUntilNextGesture() + cancelCreationInteraction(animated: false) + clearLocalGestureState() + } + + private func clearLocalGestureState() { + _ = gestureLifecycle.reset() + geometryTracker?.cancelDirectionalSnap() + inputScalingState.reset() + horizontalGestureAccumulator.reset() + } +} diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerModels.swift b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerModels.swift new file mode 100644 index 0000000..f0eb836 --- /dev/null +++ b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerModels.swift @@ -0,0 +1,295 @@ +import Foundation + +enum SidebarSpacePagerPageID: Hashable, Sendable { + case activity + case space(SpaceID) + + var spaceID: SpaceID? { + if case .space(let id) = self { + return id + } + return nil + } +} + +struct SidebarSpacePagerNavigationRequest: Equatable, Sendable { + let id = UUID() + let pageID: SidebarSpacePagerPageID +} + +struct SidebarSpacePagerSnapshot: Equatable, Sendable { + let selectedSpacePageID: SidebarSpacePagerPageID? + let selectedAuxiliaryPageID: SidebarSpacePagerPageID? + let spaceCount: Int + let pages: [SidebarSpacePagerPageSnapshot] + + var pageCount: Int { + pages.count + } +} + +enum SidebarSpacePagerPageSnapshot: Identifiable, Equatable, Sendable { + case activity(SidebarActivityPageSnapshot) + case space(SidebarSpacePageSnapshot) + + var id: SidebarSpacePagerPageID { + switch self { + case .activity: + return .activity + case .space(let page): + return .space(page.id) + } + } + + var space: BrowserSpace? { + if case .space(let page) = self { + return page.space + } + return nil + } + + var chromeTheme: SidebarChromeTheme { + switch self { + case .activity: + return .standard + case .space(let page): + return SidebarChromeTheme.theme(for: page.space) + } + } +} + +struct SidebarActivityPageSnapshot: Equatable, Sendable { + let profiles: [BrowserProfile] + let downloads: [BrowserDownload] + let historyEntries: [BrowserHistoryEntry] +} + +struct SidebarSpacePageSnapshot: Identifiable, Equatable, Sendable { + var id: SpaceID { space.id } + + let index: Int + let space: BrowserSpace + let favoriteTabs: [SidebarTabItemSnapshot] + let pinnedTabs: [SidebarTabItemSnapshot] + let folders: [SidebarFolderItemSnapshot] + let regularTabs: [SidebarTabItemSnapshot] +} + +struct SidebarFolderItemSnapshot: Identifiable, Equatable, Sendable { + var id: FolderID { folder.id } + + let folder: BrowserFolder + let tabs: [SidebarTabItemSnapshot] + let childFolders: [SidebarFolderItemSnapshot] + + static func == (lhs: SidebarFolderItemSnapshot, rhs: SidebarFolderItemSnapshot) -> Bool { + lhs.folder == rhs.folder + && lhs.tabs == rhs.tabs + && lhs.childFolders == rhs.childFolders + } +} + +struct SidebarTabItemSnapshot: Identifiable, Equatable, Sendable { + var id: TabID { tab.id } + + let tab: BrowserTab + let isSelected: Bool + let hasLiveSession: Bool + let canClose: Bool + let canMoveUp: Bool + let canMoveDown: Bool + + init( + tab: BrowserTab, + isSelected: Bool, + hasLiveSession: Bool = false, + canClose: Bool = true, + canMoveUp: Bool, + canMoveDown: Bool + ) { + self.tab = tab + self.isSelected = isSelected + self.hasLiveSession = hasLiveSession + self.canClose = canClose + self.canMoveUp = canMoveUp + self.canMoveDown = canMoveDown + } +} + +struct SidebarSpacePageSectionVisibility { + static func showsEmptyFavoriteTabDropSection( + for page: SidebarSpacePageSnapshot, + isDragging: Bool + ) -> Bool { + isDragging && page.favoriteTabs.isEmpty && hasTabsOutsideFavorites(in: page) + } + + static func showsEmptyPinnedTabDropSection( + for page: SidebarSpacePageSnapshot, + isDragging: Bool + ) -> Bool { + isDragging && page.pinnedTabs.isEmpty && hasTabsOutsidePinnedList(in: page) + } + + static func showsEmptyRegularTabDropSection( + for page: SidebarSpacePageSnapshot, + isDragging: Bool + ) -> Bool { + isDragging && page.regularTabs.isEmpty && hasTabsOutsideRegular(in: page) + } + + private static func hasTabsOutsideFavorites(in page: SidebarSpacePageSnapshot) -> Bool { + !page.pinnedTabs.isEmpty + || !page.regularTabs.isEmpty + || page.folders.contains(where: folderContainsTabs) + } + + private static func hasTabsOutsidePinnedList(in page: SidebarSpacePageSnapshot) -> Bool { + !page.favoriteTabs.isEmpty + || !page.regularTabs.isEmpty + || page.folders.contains(where: folderContainsTabs) + } + + private static func hasTabsOutsideRegular(in page: SidebarSpacePageSnapshot) -> Bool { + !page.favoriteTabs.isEmpty + || !page.pinnedTabs.isEmpty + || page.folders.contains(where: folderContainsTabs) + } + + private static func folderContainsTabs(_ folder: SidebarFolderItemSnapshot) -> Bool { + !folder.tabs.isEmpty || folder.childFolders.contains(where: folderContainsTabs) + } +} + + +struct SidebarSpacePagerFocus { + static func focusedTabID( + for space: BrowserSpace, + folders: [BrowserFolder], + tabsByID: [TabID: BrowserTab] + ) -> TabID? { + BrowserSpaceFocusedTabResolver.focusedTabID(for: space, folders: folders, tabsByID: tabsByID) + } + + static func isFocused(tabID: TabID, focusedTabID: TabID?) -> Bool { + tabID == focusedTabID + } +} + +struct SidebarSpacePageSnapshotBuilder { + static func spacePages( + activeSpaces: [BrowserSpace], + folders: [BrowserFolder], + tabs: [BrowserTab], + liveSessionTabIDs: Set = [] + ) -> [SidebarSpacePageSnapshot] { + let pageSpaceIDs = Set(activeSpaces.map(\.id)) + let foldersByID = Dictionary( + uniqueKeysWithValues: folders + .lazy + .filter { pageSpaceIDs.contains($0.parentSpaceID) } + .map { ($0.id, $0) } + ) + let foldersBySpaceID = Dictionary(grouping: foldersByID.values, by: \.parentSpaceID) + let directTabIDs = activeSpaces.flatMap { space in + space.favoriteTabIDs + space.pinnedTabIDs + space.regularTabIDs + } + let folderTabIDs = foldersByID.values.flatMap(\.tabIDs) + let visibleTabIDs = Set(directTabIDs + folderTabIDs) + let tabsByID = Dictionary( + uniqueKeysWithValues: tabs + .lazy + .filter { visibleTabIDs.contains($0.id) } + .map { ($0.id, $0) } + ) + + return activeSpaces.indices.map { index in + let space = activeSpaces[index] + let focusedTabID = SidebarSpacePagerFocus.focusedTabID( + for: space, + folders: foldersBySpaceID[space.id, default: []], + tabsByID: tabsByID + ) + + return SidebarSpacePageSnapshot( + index: index, + space: space, + favoriteTabs: tabItems( + for: space.favoriteTabIDs, + focusedTabID: focusedTabID, + tabsByID: tabsByID, + liveSessionTabIDs: liveSessionTabIDs + ), + pinnedTabs: tabItems( + for: space.pinnedTabIDs, + focusedTabID: focusedTabID, + tabsByID: tabsByID, + liveSessionTabIDs: liveSessionTabIDs + ), + folders: folderItems( + for: space.folderIDs, + focusedTabID: focusedTabID, + foldersByID: foldersByID, + tabsByID: tabsByID, + liveSessionTabIDs: liveSessionTabIDs + ), + regularTabs: tabItems( + for: space.regularTabIDs, + focusedTabID: focusedTabID, + tabsByID: tabsByID, + liveSessionTabIDs: liveSessionTabIDs + ) + ) + } + } + + private static func tabItems( + for ids: [TabID], + focusedTabID: TabID?, + tabsByID: [TabID: BrowserTab], + liveSessionTabIDs: Set + ) -> [SidebarTabItemSnapshot] { + let orderedTabs = ids.compactMap { tabsByID[$0] } + return orderedTabs.enumerated().map { index, tab in + SidebarTabItemSnapshot( + tab: tab, + isSelected: SidebarSpacePagerFocus.isFocused(tabID: tab.id, focusedTabID: focusedTabID), + hasLiveSession: liveSessionTabIDs.contains(tab.id), + canMoveUp: index > 0, + canMoveDown: index < orderedTabs.count - 1 + ) + } + } + + private static func folderItems( + for ids: [FolderID], + focusedTabID: TabID?, + foldersByID: [FolderID: BrowserFolder], + tabsByID: [TabID: BrowserTab], + liveSessionTabIDs: Set + ) -> [SidebarFolderItemSnapshot] { + ids.compactMap { id in + guard let folder = foldersByID[id] else { + return nil + } + + return SidebarFolderItemSnapshot( + folder: folder, + tabs: tabItems( + for: folder.tabIDs, + focusedTabID: focusedTabID, + tabsByID: tabsByID, + liveSessionTabIDs: liveSessionTabIDs + ), + childFolders: folderItems( + for: folder.childFolderIDs, + focusedTabID: focusedTabID, + foldersByID: foldersByID, + tabsByID: tabsByID, + liveSessionTabIDs: liveSessionTabIDs + ) + ) + } + } +} + diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift new file mode 100644 index 0000000..f1ae0a2 --- /dev/null +++ b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift @@ -0,0 +1,559 @@ +import AppKit +import SwiftUI + +enum SidebarSpacePagerMetrics { + static let selectionAnimation: Animation = .smooth(duration: 0.18, extraBounce: 0) + static let activeScrollSensitivity: CGFloat = 0.35 + static let directionalDistanceThresholdInPages: CGFloat = 0.0025 + static let directionalVelocityThreshold: CGFloat = 20 + static let creationDirectionInferenceDistance: CGFloat = 2 + static let creationSettledOffsetTolerance: CGFloat = 0.75 + static let creationPullThreshold: CGFloat = 160 + static let creationPullResistance: CGFloat = 0.55 + static let creationRailMaximumWidth: CGFloat = 72 + static let creationAffordanceDiameter: CGFloat = 32 + static let creationRailTrailingPadding: CGFloat = 12 + static let creationReturnAnimation: Animation = .smooth(duration: 0.24, extraBounce: 0.08) +} + +enum SidebarSpacePagerHorizontalIntent: Equatable, Sendable { + case backward + case forward + + static func intent( + forAdjustedDisplacement displacement: CGFloat, + minimumDisplacement: CGFloat + ) -> Self? { + guard displacement.isFinite, + minimumDisplacement.isFinite, + abs(displacement) >= max(minimumDisplacement, 0), + displacement != 0 else { + return nil + } + return displacement > 0 ? .forward : .backward + } +} + +/// Converts AppKit wheel coordinates into the pager's content coordinates. +/// `NSScrollView` moves its horizontal content offset opposite to +/// `NSEvent.scrollingDeltaX`, so this conversion is the only place where that +/// sign inversion belongs. +struct SidebarSpacePagerHorizontalInputSample: Equatable, Sendable { + let adjustedDisplacementX: CGFloat + + init?(scrollingDeltaX: CGFloat) { + guard scrollingDeltaX.isFinite else { + return nil + } + adjustedDisplacementX = -scrollingDeltaX + } +} + +struct SidebarSpacePagerHorizontalGestureAccumulator: Equatable, Sendable { + private(set) var cumulativeAdjustedDisplacementX: CGFloat = 0 + + mutating func append(_ sample: SidebarSpacePagerHorizontalInputSample) { + cumulativeAdjustedDisplacementX += sample.adjustedDisplacementX + } + + mutating func reset() { + cumulativeAdjustedDisplacementX = 0 + } +} + +/// Non-observable gesture geometry shared by SwiftUI's phase callbacks and the +/// scroll-target policy. Live samples never invalidate the pager view tree. +final class SidebarSpacePagerGeometryTracker { + var visibleFractionalPageIndex: CGFloat? + var gestureStartFractionalPageIndex: CGFloat? + var gestureSourcePageIndex: Int? + var adjustedGestureDisplacementX: CGFloat? + var acceptsDirectionalSnap = false + private(set) var resolvedTargetPageIndex: Int? + private(set) var creationPullLocksLastPage = false + private(set) var activePhysicalGestureID: UInt64? + private var scrollPhase = ScrollPhase.idle + + var scrollIsIdle: Bool { + scrollPhase == .idle && activePhysicalGestureID == nil + } + + func transition(from oldPhase: ScrollPhase, to newPhase: ScrollPhase) { + scrollPhase = newPhase + switch newPhase { + case .tracking: + if activePhysicalGestureID == nil { + beginDirectionalSnap( + sourcePageIndex: interruptedTargetPageIndex(after: oldPhase) + ) + } else { + acceptsDirectionalSnap = true + } + case .interacting: + if activePhysicalGestureID != nil { + acceptsDirectionalSnap = true + } else if oldPhase != .tracking { + beginDirectionalSnap( + sourcePageIndex: interruptedTargetPageIndex(after: oldPhase) + ) + } else { + acceptsDirectionalSnap = true + } + case .decelerating: + acceptsDirectionalSnap = true + case .animating: + // Preserve the captured gesture origin through SwiftUI's alignment + // animation. Programmatic animations begin from idle and opt out. + if oldPhase == .idle, activePhysicalGestureID == nil { + resetDirectionalSnap() + } + case .idle: + if activePhysicalGestureID == nil { + resetDirectionalSnap() + } + } + } + + func cancelDirectionalSnap() { + activePhysicalGestureID = nil + resetDirectionalSnap() + } + + func beginPhysicalGesture(id: UInt64) { + activePhysicalGestureID = id + creationPullLocksLastPage = false + beginDirectionalSnap(sourcePageIndex: resolvedTargetPageIndex) + } + + func endPhysicalGesture(id: UInt64) { + guard activePhysicalGestureID == id else { + return + } + + activePhysicalGestureID = nil + if scrollPhase == .idle { + resetDirectionalSnap() + } + } + + func setCreationPullLocksLastPage(_ isLocked: Bool) { + creationPullLocksLastPage = isLocked + } + + func clearAdjustedGestureDisplacement() { + adjustedGestureDisplacementX = nil + } + + func recordResolvedTargetPageIndex(_ pageIndex: Int) { + resolvedTargetPageIndex = pageIndex + } + + private func resetDirectionalSnap() { + acceptsDirectionalSnap = false + gestureStartFractionalPageIndex = nil + gestureSourcePageIndex = nil + adjustedGestureDisplacementX = nil + resolvedTargetPageIndex = nil + creationPullLocksLastPage = false + } + + private func interruptedTargetPageIndex(after phase: ScrollPhase) -> Int? { + switch phase { + case .animating, .decelerating: + return resolvedTargetPageIndex + case .idle, .tracking, .interacting: + return nil + } + } + + private func beginDirectionalSnap(sourcePageIndex: Int?) { + gestureStartFractionalPageIndex = visibleFractionalPageIndex + gestureSourcePageIndex = sourcePageIndex + adjustedGestureDisplacementX = nil + acceptsDirectionalSnap = true + } +} + +/// Rewrites only the active horizontal displacement fields. Gesture direction +/// accumulation is deliberately owned by `SidebarSpacePagerHorizontalGestureAccumulator`. +struct SidebarSpacePagerScrollInputScalingState { + private var pointDeltaResidualX = 0.0 + + mutating func scaledEvent( + from event: NSEvent, + sensitivity: CGFloat + ) -> NSEvent? { + guard event.type == .scrollWheel, + let copiedCGEvent = event.cgEvent?.copy() else { + return nil + } + + let normalizedSensitivity = Self.normalizedSensitivity(sensitivity) + let originalFixedPointDeltaX = copiedCGEvent.getDoubleValueField( + .scrollWheelEventFixedPtDeltaAxis2 + ) + let originalPointDeltaX = copiedCGEvent.getIntegerValueField( + .scrollWheelEventPointDeltaAxis2 + ) + if originalFixedPointDeltaX.isFinite { + copiedCGEvent.setDoubleValueField( + .scrollWheelEventFixedPtDeltaAxis2, + value: originalFixedPointDeltaX * normalizedSensitivity + ) + } + copiedCGEvent.setIntegerValueField( + .scrollWheelEventPointDeltaAxis2, + value: Self.scaledIntegralDelta( + originalPointDeltaX, + sensitivity: normalizedSensitivity, + residual: &pointDeltaResidualX + ) + ) + + return NSEvent(cgEvent: copiedCGEvent) + } + + mutating func reset() { + pointDeltaResidualX = 0 + } + + static func normalizedSensitivity(_ sensitivity: CGFloat) -> Double { + guard sensitivity.isFinite else { + return 1 + } + + return Double(min(max(sensitivity, 0), 1)) + } + + private static func scaledIntegralDelta( + _ delta: Int64, + sensitivity: Double, + residual: inout Double + ) -> Int64 { + let exactValue = Double(delta) * sensitivity + residual + guard exactValue.isFinite, + exactValue > Double(Int64.min), + exactValue < Double(Int64.max) else { + residual = 0 + return delta + } + + let scaledValue = Int64(exactValue.rounded(.toNearestOrAwayFromZero)) + residual = exactValue - Double(scaledValue) + return scaledValue + } +} + +struct SidebarSpacePagerPhysicalGestureGate { + private(set) var ignoresChangedEvents = false + + mutating func begin() { + ignoresChangedEvents = false + } + + mutating func ignoreChangedEventsUntilNextGesture() { + ignoresChangedEvents = true + } + + mutating func end() { + ignoresChangedEvents = false + } +} + +struct SidebarSpacePagerFixedChromeTarget { + static func pageID( + visibleFractionalPageIndex: CGFloat, + gestureStartFractionalPageIndex: CGFloat?, + gestureSourcePageIndex: Int? = nil, + adjustedGestureDisplacementX: CGFloat?, + pageWidth: CGFloat, + pages: [SidebarSpacePagerPageSnapshot] + ) -> SidebarSpacePagerPageID? { + guard !pages.isEmpty, + visibleFractionalPageIndex.isFinite, + pageWidth.isFinite, + pageWidth > 0 else { + return nil + } + + let gestureOrigin: CGFloat + if let gestureStartFractionalPageIndex, + gestureStartFractionalPageIndex.isFinite { + gestureOrigin = gestureStartFractionalPageIndex + } else { + gestureOrigin = visibleFractionalPageIndex.rounded() + } + + let pageIndex = SidebarSpacePagerSnap.targetPageIndex( + originalOffsetX: gestureOrigin * pageWidth, + proposedOffsetX: visibleFractionalPageIndex * pageWidth, + gestureStartFractionalPageIndex: gestureOrigin, + gestureSourcePageIndex: gestureSourcePageIndex, + visibleFractionalPageIndex: visibleFractionalPageIndex, + adjustedGestureDisplacementX: adjustedGestureDisplacementX, + velocityX: 0, + pageWidth: pageWidth, + pageCount: pages.count + ) + return pages[pageIndex].id + } +} + +struct SidebarSpacePagerChrome { + static func shouldDeferSettledStyle( + from currentPageID: SidebarSpacePagerPageID?, + to targetPageID: SidebarSpacePagerPageID? + ) -> Bool { + guard let targetPageID else { + return false + } + return currentPageID != targetPageID + } + + static func theme( + for pageID: SidebarSpacePagerPageID?, + in pages: [SidebarSpacePagerPageSnapshot] + ) -> SidebarChromeTheme? { + guard let pageID else { + return nil + } + return pages.first(where: { $0.id == pageID })?.chromeTheme + } + + static func color( + for pageID: SidebarSpacePagerPageID?, + in pages: [SidebarSpacePagerPageSnapshot] + ) -> SidebarChromeColor? { + theme(for: pageID, in: pages).map(SidebarChromeColor.init(theme:)) + } + + static func liveStyle( + for pageID: SidebarSpacePagerPageID?, + in pages: [SidebarSpacePagerPageSnapshot] + ) -> SidebarChromeLiveStyle? { + theme(for: pageID, in: pages).map(SidebarChromeLiveStyle.init(theme:)) + } + + static func liveColor( + at fractionalPageIndex: CGFloat, + in pages: [SidebarSpacePagerPageSnapshot] + ) -> SidebarChromeColor? { + liveColor(at: fractionalPageIndex, themes: pages.map(\.chromeTheme)) + } + + static func liveColor( + at fractionalPageIndex: CGFloat, + themes: [SidebarChromeTheme] + ) -> SidebarChromeColor? { + SidebarChromeColor.interpolated( + themes: themes, + fractionalIndex: Double(fractionalPageIndex) + ) + } + + static func liveStyle( + at fractionalPageIndex: CGFloat, + themes: [SidebarChromeTheme] + ) -> SidebarChromeLiveStyle? { + SidebarChromeLiveStyle.interpolated( + themes: themes, + fractionalIndex: Double(fractionalPageIndex) + ) + } + + static func liveStyle( + at fractionalPageIndex: CGFloat, + styles: [SidebarChromeLiveStyle] + ) -> SidebarChromeLiveStyle? { + SidebarChromeLiveStyle.interpolated( + styles: styles, + fractionalIndex: Double(fractionalPageIndex) + ) + } +} + +struct SidebarSpacePagerSelection { + static func committedPageID( + scrollPositionPageID: SidebarSpacePagerPageID?, + selectedPageID: SidebarSpacePagerPageID?, + pageIDs: [SidebarSpacePagerPageID] + ) -> SidebarSpacePagerPageID? { + guard let scrollPositionPageID, + scrollPositionPageID != selectedPageID, + pageIDs.contains(scrollPositionPageID) else { + return nil + } + return scrollPositionPageID + } +} + +struct SidebarSpacePagerPreview { + static func spaceID( + for targetPageID: SidebarSpacePagerPageID, + selectedPageID: SidebarSpacePagerPageID? + ) -> SpaceID? { + guard targetPageID != selectedPageID, + case .space(let spaceID) = targetPageID else { + return nil + } + return spaceID + } +} + +struct SidebarAdjacentPageScrollTargetBehavior: ScrollTargetBehavior { + let pageCount: Int + let geometryTracker: SidebarSpacePagerGeometryTracker + + func updateTarget(_ target: inout ScrollTarget, context: TargetContext) { + guard context.axes.contains(.horizontal), + pageCount > 0 else { + return + } + + let pageWidth = context.containerSize.width + guard pageWidth.isFinite, + pageWidth > 0 else { + return + } + + let targetPageIndex: Int + if geometryTracker.creationPullLocksLastPage { + targetPageIndex = pageCount - 1 + } else if geometryTracker.acceptsDirectionalSnap { + targetPageIndex = SidebarSpacePagerSnap.targetPageIndex( + originalOffsetX: context.originalTarget.rect.minX, + proposedOffsetX: target.rect.minX, + gestureStartFractionalPageIndex: geometryTracker.gestureStartFractionalPageIndex, + gestureSourcePageIndex: geometryTracker.gestureSourcePageIndex, + visibleFractionalPageIndex: geometryTracker.visibleFractionalPageIndex, + adjustedGestureDisplacementX: geometryTracker.adjustedGestureDisplacementX, + velocityX: context.velocity.dx, + pageWidth: pageWidth, + pageCount: pageCount + ) + } else { + targetPageIndex = SidebarSpacePagerSnap.nearestPageIndex( + offsetX: target.rect.minX, + pageWidth: pageWidth, + pageCount: pageCount + ) + } + + geometryTracker.recordResolvedTargetPageIndex(targetPageIndex) + target.rect.origin.x = CGFloat(targetPageIndex) * pageWidth + target.anchor = .topLeading + } +} + +struct SidebarSpacePagerSnap { + static func targetPageIndex( + originalOffsetX: CGFloat, + proposedOffsetX: CGFloat, + gestureStartFractionalPageIndex: CGFloat? = nil, + gestureSourcePageIndex: Int? = nil, + visibleFractionalPageIndex: CGFloat?, + adjustedGestureDisplacementX: CGFloat? = nil, + velocityX: CGFloat, + pageWidth: CGFloat, + pageCount: Int + ) -> Int { + guard pageCount > 0, + pageWidth.isFinite, + pageWidth > 0 else { + return 0 + } + + let originalPagePosition = finiteOffset(originalOffsetX) / pageWidth + let gestureStartPagePosition = finiteValue(gestureStartFractionalPageIndex) + ?? originalPagePosition + let currentPageIndex = clampedPageIndex( + gestureSourcePageIndex ?? Int(gestureStartPagePosition.rounded()), + pageCount: pageCount + ) + let proposedDirection: Int? + if originalOffsetX.isFinite, proposedOffsetX.isFinite { + let proposedPagePosition = proposedOffsetX / pageWidth + proposedDirection = pageDirection(from: proposedPagePosition - gestureStartPagePosition) + } else { + proposedDirection = nil + } + + let visibleDirection: Int? + if let visibleFractionalPageIndex, + visibleFractionalPageIndex.isFinite { + visibleDirection = pageDirection( + from: visibleFractionalPageIndex - gestureStartPagePosition + ) + } else { + visibleDirection = nil + } + + let adjustedGestureDirection: Int? + if let adjustedGestureDisplacementX, + adjustedGestureDisplacementX.isFinite { + adjustedGestureDirection = pageDirection( + from: adjustedGestureDisplacementX / pageWidth + ) + } else { + adjustedGestureDirection = nil + } + + let pageStep = proposedDirection + ?? velocityDirection(from: velocityX) + ?? adjustedGestureDirection + ?? visibleDirection + ?? 0 + return clampedPageIndex(currentPageIndex + pageStep, pageCount: pageCount) + } + + static func nearestPageIndex( + offsetX: CGFloat, + pageWidth: CGFloat, + pageCount: Int + ) -> Int { + guard pageCount > 0, + pageWidth.isFinite, + pageWidth > 0 else { + return 0 + } + + return clampedPageIndex( + Int((finiteOffset(offsetX) / pageWidth).rounded()), + pageCount: pageCount + ) + } + + private static func finiteOffset(_ offset: CGFloat) -> CGFloat { + offset.isFinite ? offset : 0 + } + + private static func finiteValue(_ value: CGFloat?) -> CGFloat? { + guard let value, + value.isFinite else { + return nil + } + return value + } + + private static func pageDirection(from delta: CGFloat) -> Int? { + guard let intent = SidebarSpacePagerHorizontalIntent.intent( + forAdjustedDisplacement: delta, + minimumDisplacement: SidebarSpacePagerMetrics.directionalDistanceThresholdInPages + ) else { + return nil + } + return intent == .forward ? 1 : -1 + } + + private static func velocityDirection(from velocityX: CGFloat) -> Int? { + guard velocityX.isFinite, + abs(velocityX) >= SidebarSpacePagerMetrics.directionalVelocityThreshold else { + return nil + } + return velocityX > 0 ? 1 : -1 + } + + private static func clampedPageIndex(_ pageIndex: Int, pageCount: Int) -> Int { + min(max(pageIndex, 0), pageCount - 1) + } +} diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarView.swift b/Sources/MeridianCore/Views/Sidebar/SidebarView.swift index 3b8ba44..9bdc643 100644 --- a/Sources/MeridianCore/Views/Sidebar/SidebarView.swift +++ b/Sources/MeridianCore/Views/Sidebar/SidebarView.swift @@ -117,13 +117,6 @@ private struct SidebarFixedChromeForeground: View { .environment(\.sidebarForegroundColor, foregroundColor) .environment(\.sidebarForegroundWhiteAmount, whiteAmount) .environment(\.sidebarUsesDarkForeground, whiteAmount < 0.5) - // The target changes once when the gesture acquires a direction, - // not for every pixel of travel. SwiftUI can therefore animate the - // small fixed chrome without re-entering its render loop at 120 Hz. - .animation( - SidebarSpacePagerMetrics.fixedChromeThemeAnimation, - value: whiteAmount - ) } } @@ -622,6 +615,7 @@ public struct SidebarView: View { revealDownload: { revealDownload($0) }, selectSpace: { selectSpace($0) }, selectAuxiliaryPage: { selectAuxiliaryPage($0) }, + createSpace: { _ = store.createSpace(name: "New Space") }, previewSpace: { setPreviewSpace($0) }, sidebarIsPinned: store.sidebarIsLockedOpen, updateSidebarFixedChromeLiveStyle: { @@ -774,6 +768,114 @@ struct SidebarAddressMorphState: Equatable, Sendable { let progress: Double } +struct SidebarAddressMorphPlan: Equatable, Sendable { + let sharedPrefixCount: Int + let sourceChangedCount: Int + let destinationChangedCount: Int + let sharedSuffixCount: Int + + init(sourceText: String, destinationText: String) { + let sourceCharacters = Array(sourceText) + let destinationCharacters = Array(destinationText) + let sharedPrefixCount = zip(sourceCharacters, destinationCharacters) + .prefix { $0 == $1 } + .count + let remainingSource = sourceCharacters.dropFirst(sharedPrefixCount) + let remainingDestination = destinationCharacters.dropFirst(sharedPrefixCount) + let sharedSuffixCount = zip(remainingSource.reversed(), remainingDestination.reversed()) + .prefix { $0 == $1 } + .count + + self.sharedPrefixCount = sharedPrefixCount + self.sharedSuffixCount = sharedSuffixCount + sourceChangedCount = max(sourceCharacters.count - sharedPrefixCount - sharedSuffixCount, 0) + destinationChangedCount = max(destinationCharacters.count - sharedPrefixCount - sharedSuffixCount, 0) + } +} + +enum SidebarAddressGlyphRole: Equatable, Sendable { + case source + case destination +} + +struct SidebarAddressGlyphRenderState: Equatable, Sendable { + let opacity: Double + let translationY: CGFloat + let blurProgress: Double +} + +enum SidebarAddressGlyphMorph { + static func state( + progress: Double, + role: SidebarAddressGlyphRole, + sliceIndex: Int, + sliceCount: Int, + morphPlan: SidebarAddressMorphPlan + ) -> SidebarAddressGlyphRenderState { + guard sliceCount > 0, + sliceIndex >= 0, + sliceIndex < sliceCount else { + return SidebarAddressGlyphRenderState(opacity: 0, translationY: 0, blurProgress: 0) + } + + let clampedProgress = min(max(progress, 0), 1) + let smoothProgress = clampedProgress * clampedProgress * (3 - 2 * clampedProgress) + let prefixCount = min(morphPlan.sharedPrefixCount, sliceCount) + let suffixCount = min( + morphPlan.sharedSuffixCount, + max(sliceCount - prefixCount, 0) + ) + let changedRange = prefixCount.. 1 + ? Double(sliceIndex - changedRange.lowerBound) / Double(changedCount - 1) + : 0 + let delay = sequencePosition * 0.18 + let localProgress = min(max((clampedProgress - delay) / (1 - 0.18), 0), 1) + let easedProgress = localProgress * localProgress * (3 - 2 * localProgress) + let visibility = role == .source ? 1 - easedProgress : easedProgress + let blurProgress = role == .source ? easedProgress : 1 - easedProgress + let verticalDirection = role == .source ? -1.0 : 1.0 + + if visibility <= 0 { + return SidebarAddressGlyphRenderState( + opacity: 0, + translationY: 0, + blurProgress: 0 + ) + } + + return SidebarAddressGlyphRenderState( + opacity: visibility, + translationY: verticalDirection * blurProgress * 1.5, + blurProgress: blurProgress + ) + } +} + enum SidebarAddressScrollMorph { static func state( at fractionalPageIndex: CGFloat, @@ -863,16 +965,23 @@ private struct SidebarAddressMorphingText: View { var body: some View { ZStack(alignment: .leading) { if let state = controller.state { + let morphPlan = SidebarAddressMorphPlan( + sourceText: state.sourceText, + destinationText: state.destinationText + ) + Text(state.sourceText) .textRenderer(SidebarAddressGlyphRenderer( progress: state.progress, - role: .source + role: .source, + morphPlan: morphPlan )) Text(state.destinationText) .textRenderer(SidebarAddressGlyphRenderer( progress: state.progress, - role: .destination + role: .destination, + morphPlan: morphPlan )) } else { Text(settledText) @@ -884,36 +993,37 @@ private struct SidebarAddressMorphingText: View { } private struct SidebarAddressGlyphRenderer: TextRenderer { - enum Role { - case source - case destination - } - let progress: Double - let role: Role + let role: SidebarAddressGlyphRole + let morphPlan: SidebarAddressMorphPlan func draw(layout: Text.Layout, in context: inout GraphicsContext) { let slices = layout.sidebarFlattenedRunSlices - let lastSliceIndex = max(slices.count - 1, 1) + guard !slices.isEmpty else { + return + } for (index, slice) in slices.enumerated() { - let sequencePosition = Double(index) / Double(lastSliceIndex) - let delay = sequencePosition * 0.22 - let localProgress = min(max((progress - delay) / (1 - 0.22), 0), 1) - let easedProgress = localProgress * localProgress * (3 - 2 * localProgress) - let visibility = role == .source ? 1 - easedProgress : easedProgress - let blurProgress = role == .source ? easedProgress : 1 - easedProgress - let verticalDirection = role == .source ? -1.0 : 1.0 - var copy = context - copy.opacity = visibility - copy.translateBy( - x: 0, - y: verticalDirection * blurProgress * 1.5 + let renderState = SidebarAddressGlyphMorph.state( + progress: progress, + role: role, + sliceIndex: index, + sliceCount: slices.count, + morphPlan: morphPlan ) - copy.addFilter(.blur( - radius: slice.typographicBounds.rect.height / 14 * blurProgress - )) + guard renderState.opacity > 0.001 else { + continue + } + + copy.opacity = renderState.opacity + copy.translateBy(x: 0, y: renderState.translationY) + if renderState.blurProgress > 0.001 { + copy.addFilter(.blur( + radius: slice.typographicBounds.rect.height / 14 + * renderState.blurProgress + )) + } copy.draw(slice, options: .disablesSubpixelQuantization) } } @@ -2826,167 +2936,6 @@ private struct WindowReader: NSViewRepresentable { } } -enum SidebarSpacePagerPageID: Hashable, Sendable { - case activity - case space(SpaceID) - - var spaceID: SpaceID? { - if case .space(let id) = self { - return id - } - return nil - } -} - -struct SidebarSpacePagerNavigationRequest: Equatable, Sendable { - let id = UUID() - let pageID: SidebarSpacePagerPageID -} - -struct SidebarSpacePagerSnapshot: Equatable, Sendable { - let selectedSpacePageID: SidebarSpacePagerPageID? - let selectedAuxiliaryPageID: SidebarSpacePagerPageID? - let spaceCount: Int - let pages: [SidebarSpacePagerPageSnapshot] - - var pageCount: Int { - pages.count - } -} - -enum SidebarSpacePagerPageSnapshot: Identifiable, Equatable, Sendable { - case activity(SidebarActivityPageSnapshot) - case space(SidebarSpacePageSnapshot) - - var id: SidebarSpacePagerPageID { - switch self { - case .activity: - return .activity - case .space(let page): - return .space(page.id) - } - } - - var space: BrowserSpace? { - if case .space(let page) = self { - return page.space - } - return nil - } - - var chromeTheme: SidebarChromeTheme { - switch self { - case .activity: - return .standard - case .space(let page): - return SidebarChromeTheme.theme(for: page.space) - } - } -} - -struct SidebarActivityPageSnapshot: Equatable, Sendable { - let profiles: [BrowserProfile] - let downloads: [BrowserDownload] - let historyEntries: [BrowserHistoryEntry] -} - -struct SidebarSpacePageSnapshot: Identifiable, Equatable, Sendable { - var id: SpaceID { space.id } - - let index: Int - let space: BrowserSpace - let favoriteTabs: [SidebarTabItemSnapshot] - let pinnedTabs: [SidebarTabItemSnapshot] - let folders: [SidebarFolderItemSnapshot] - let regularTabs: [SidebarTabItemSnapshot] -} - -struct SidebarFolderItemSnapshot: Identifiable, Equatable, Sendable { - var id: FolderID { folder.id } - - let folder: BrowserFolder - let tabs: [SidebarTabItemSnapshot] - let childFolders: [SidebarFolderItemSnapshot] - - static func == (lhs: SidebarFolderItemSnapshot, rhs: SidebarFolderItemSnapshot) -> Bool { - lhs.folder == rhs.folder - && lhs.tabs == rhs.tabs - && lhs.childFolders == rhs.childFolders - } -} - -struct SidebarTabItemSnapshot: Identifiable, Equatable, Sendable { - var id: TabID { tab.id } - - let tab: BrowserTab - let isSelected: Bool - let hasLiveSession: Bool - let canClose: Bool - let canMoveUp: Bool - let canMoveDown: Bool - - init( - tab: BrowserTab, - isSelected: Bool, - hasLiveSession: Bool = false, - canClose: Bool = true, - canMoveUp: Bool, - canMoveDown: Bool - ) { - self.tab = tab - self.isSelected = isSelected - self.hasLiveSession = hasLiveSession - self.canClose = canClose - self.canMoveUp = canMoveUp - self.canMoveDown = canMoveDown - } -} - -struct SidebarSpacePageSectionVisibility { - static func showsEmptyFavoriteTabDropSection( - for page: SidebarSpacePageSnapshot, - isDragging: Bool - ) -> Bool { - isDragging && page.favoriteTabs.isEmpty && hasTabsOutsideFavorites(in: page) - } - - static func showsEmptyPinnedTabDropSection( - for page: SidebarSpacePageSnapshot, - isDragging: Bool - ) -> Bool { - isDragging && page.pinnedTabs.isEmpty && hasTabsOutsidePinnedList(in: page) - } - - static func showsEmptyRegularTabDropSection( - for page: SidebarSpacePageSnapshot, - isDragging: Bool - ) -> Bool { - isDragging && page.regularTabs.isEmpty && hasTabsOutsideRegular(in: page) - } - - private static func hasTabsOutsideFavorites(in page: SidebarSpacePageSnapshot) -> Bool { - !page.pinnedTabs.isEmpty - || !page.regularTabs.isEmpty - || page.folders.contains(where: folderContainsTabs) - } - - private static func hasTabsOutsidePinnedList(in page: SidebarSpacePageSnapshot) -> Bool { - !page.favoriteTabs.isEmpty - || !page.regularTabs.isEmpty - || page.folders.contains(where: folderContainsTabs) - } - - private static func hasTabsOutsideRegular(in page: SidebarSpacePageSnapshot) -> Bool { - !page.favoriteTabs.isEmpty - || !page.pinnedTabs.isEmpty - || page.folders.contains(where: folderContainsTabs) - } - - private static func folderContainsTabs(_ folder: SidebarFolderItemSnapshot) -> Bool { - !folder.tabs.isEmpty || folder.childFolders.contains(where: folderContainsTabs) - } -} - private struct SidebarSpacePagerView: View { let snapshot: SidebarSpacePagerSnapshot let navigationRequest: SidebarSpacePagerNavigationRequest? @@ -3003,6 +2952,7 @@ private struct SidebarSpacePagerView: View { let revealDownload: (BrowserDownload) -> Void let selectSpace: (SpaceID) -> Void let selectAuxiliaryPage: (SidebarSpacePagerPageID?) -> Void + let createSpace: () -> Void let previewSpace: (SpaceID?) -> Void let sidebarIsPinned: Bool let updateSidebarFixedChromeLiveStyle: (SidebarChromeLiveStyle?) -> Void @@ -3017,6 +2967,7 @@ private struct SidebarSpacePagerView: View { // This tracker is deliberately non-observable. The target behavior needs the // latest offset, but publishing every offset would rebuild the pager per frame. @State private var geometryTracker = SidebarSpacePagerGeometryTracker() + @StateObject private var creationPullController = SidebarSpaceCreationPullController() var body: some View { GeometryReader { proxy in @@ -3027,6 +2978,14 @@ private struct SidebarSpacePagerView: View { let pageChromeLiveStyles = snapshot.pages.map { SidebarChromeLiveStyle(theme: $0.chromeTheme) } + let creationRailWhiteAmount = SidebarForegroundPalette.whiteAmount( + for: pageChromeLiveStyles.last ?? SidebarChromeLiveStyle(theme: .standard), + isPinned: sidebarIsPinned, + colorScheme: colorScheme + ) + let creationRailForegroundColor = SidebarForegroundPalette.color( + whiteAmount: creationRailWhiteAmount + ) ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 0) { @@ -3043,7 +3002,11 @@ private struct SidebarSpacePagerView: View { SidebarSpacePagerScrollInputScalingInstaller( sensitivity: SidebarSpacePagerMetrics.activeScrollSensitivity, pageWidth: pageWidth, - geometryTracker: geometryTracker + pageCount: snapshot.pageCount, + creationIsAvailable: creationIsAvailable, + geometryTracker: geometryTracker, + creationPullController: creationPullController, + createSpace: createSpace ) .allowsHitTesting(false) } @@ -3194,30 +3157,31 @@ private struct SidebarSpacePagerView: View { ) ) - // The full scroll-linked style terminates at retained layers. - // Fixed controls receive the exact adjacent endpoint instead; - // their controller deduplicates the hundreds of later samples. + // Keep the fixed controls on the same live interpolation path + // as the retained glass treatment so text and icon colors track + // the gesture linearly instead of easing toward one endpoint. updateSidebarChromeLiveStyle( SidebarSpacePagerChrome.liveStyle( at: newState, styles: pageChromeLiveStyles ) ) + updateSidebarFixedChromeLiveStyle( + SidebarSpacePagerChrome.liveStyle( + at: newState, + styles: pageChromeLiveStyles + ) + ) if geometryTracker.acceptsDirectionalSnap, let targetPageID = SidebarSpacePagerFixedChromeTarget.pageID( visibleFractionalPageIndex: newState, gestureStartFractionalPageIndex: geometryTracker.gestureStartFractionalPageIndex, gestureSourcePageIndex: geometryTracker.gestureSourcePageIndex, - rawGestureDisplacementX: geometryTracker.rawGestureDisplacementX, + adjustedGestureDisplacementX: + geometryTracker.adjustedGestureDisplacementX, pageWidth: pageWidth, pages: snapshot.pages ) { - updateSidebarFixedChromeLiveStyle( - SidebarSpacePagerChrome.liveStyle( - for: targetPageID, - in: snapshot.pages - ) - ) previewSpace( SidebarSpacePagerPreview.spaceID( for: targetPageID, @@ -3228,11 +3192,16 @@ private struct SidebarSpacePagerView: View { } .onDisappear { geometryTracker.cancelDirectionalSnap() + creationPullController.returnToRest(animated: false) updateSidebarChromeLiveStyle(nil) updateSidebarFixedChromeLiveStyle(nil) updateAddressMorph(nil) previewSpace(nil) } + .modifier(SidebarSpaceCreationPullPresentationModifier( + controller: creationPullController, + foregroundColor: creationRailForegroundColor + )) } } @@ -3294,6 +3263,14 @@ private struct SidebarSpacePagerView: View { snapshot.selectedAuxiliaryPageID ?? snapshot.selectedSpacePageID } + private var creationIsAvailable: Bool { + guard let lastPageID = snapshot.pages.last?.id, + case .space = lastPageID else { + return false + } + return true + } + private func pageTravelIsRequired(to pageID: SidebarSpacePagerPageID?) -> Bool { guard let pageID, let targetIndex = snapshot.pages.firstIndex(where: { $0.id == pageID }) else { @@ -3360,629 +3337,6 @@ private struct SidebarSpacePagerView: View { } } -final class SidebarSpacePagerGeometryTracker { - var visibleFractionalPageIndex: CGFloat? - var gestureStartFractionalPageIndex: CGFloat? - var gestureSourcePageIndex: Int? - var rawGestureDisplacementX: CGFloat? - var acceptsDirectionalSnap = false - private(set) var resolvedTargetPageIndex: Int? - private var physicalGestureStartIsPending = false - - func transition(from oldPhase: ScrollPhase, to newPhase: ScrollPhase) { - switch newPhase { - case .tracking: - if !consumePendingPhysicalGestureStart() { - beginDirectionalSnap( - sourcePageIndex: interruptedTargetPageIndex(after: oldPhase) - ) - } - case .interacting: - if oldPhase != .tracking { - if !consumePendingPhysicalGestureStart() { - beginDirectionalSnap( - sourcePageIndex: interruptedTargetPageIndex(after: oldPhase) - ) - } - } else { - physicalGestureStartIsPending = false - acceptsDirectionalSnap = true - } - case .decelerating: - acceptsDirectionalSnap = true - case .animating: - // A short trackpad swipe can transition directly from interaction - // into SwiftUI's page-alignment animation. Keep the captured origin - // and direction alive for that animation so a second target pass - // cannot reinterpret the damped offset as a non-gesture resize and - // snap back to the page we started on. Programmatic animations begin - // from idle (and callers also cancel explicitly), so they remain - // non-directional. - if oldPhase == .idle { - cancelDirectionalSnap() - } - case .idle: - cancelDirectionalSnap() - } - } - - func cancelDirectionalSnap() { - acceptsDirectionalSnap = false - gestureStartFractionalPageIndex = nil - gestureSourcePageIndex = nil - rawGestureDisplacementX = nil - resolvedTargetPageIndex = nil - physicalGestureStartIsPending = false - } - - func beginPhysicalGesture() { - beginDirectionalSnap(sourcePageIndex: resolvedTargetPageIndex) - physicalGestureStartIsPending = true - } - - func recordResolvedTargetPageIndex(_ pageIndex: Int) { - resolvedTargetPageIndex = pageIndex - } - - private func interruptedTargetPageIndex(after phase: ScrollPhase) -> Int? { - switch phase { - case .animating, .decelerating: - return resolvedTargetPageIndex - case .idle, .tracking, .interacting: - return nil - } - } - - private func beginDirectionalSnap(sourcePageIndex: Int?) { - gestureStartFractionalPageIndex = visibleFractionalPageIndex - gestureSourcePageIndex = sourcePageIndex - rawGestureDisplacementX = nil - acceptsDirectionalSnap = true - } - - private func consumePendingPhysicalGestureStart() -> Bool { - guard physicalGestureStartIsPending else { - return false - } - - physicalGestureStartIsPending = false - acceptsDirectionalSnap = true - return true - } -} - -struct SidebarSpacePagerScrollInputScalingState { - private var pointDeltaResidualX = 0.0 - private(set) var cumulativeRawDisplacementX: CGFloat = 0 - - mutating func scaledEvent( - from event: NSEvent, - sensitivity: CGFloat - ) -> NSEvent? { - guard event.type == .scrollWheel, - let copiedCGEvent = event.cgEvent?.copy() else { - return nil - } - - if event.scrollingDeltaX.isFinite { - cumulativeRawDisplacementX += event.scrollingDeltaX - } - - let normalizedSensitivity = Self.normalizedSensitivity(sensitivity) - let originalFixedPointDeltaX = copiedCGEvent.getDoubleValueField( - .scrollWheelEventFixedPtDeltaAxis2 - ) - let originalPointDeltaX = copiedCGEvent.getIntegerValueField( - .scrollWheelEventPointDeltaAxis2 - ) - // For precise input AppKit reads active displacement from the fixed - // and point fields. Leave the legacy, raw, and accelerated fields - // untouched so the system retains the physical release velocity. - if originalFixedPointDeltaX.isFinite { - copiedCGEvent.setDoubleValueField( - .scrollWheelEventFixedPtDeltaAxis2, - value: originalFixedPointDeltaX * normalizedSensitivity - ) - } - copiedCGEvent.setIntegerValueField( - .scrollWheelEventPointDeltaAxis2, - value: Self.scaledIntegralDelta( - originalPointDeltaX, - sensitivity: normalizedSensitivity, - residual: &pointDeltaResidualX - ) - ) - - return NSEvent(cgEvent: copiedCGEvent) - } - - mutating func reset() { - pointDeltaResidualX = 0 - cumulativeRawDisplacementX = 0 - } - - static func normalizedSensitivity(_ sensitivity: CGFloat) -> Double { - guard sensitivity.isFinite else { - return 1 - } - - return Double(min(max(sensitivity, 0), 1)) - } - - private static func scaledIntegralDelta( - _ delta: Int64, - sensitivity: Double, - residual: inout Double - ) -> Int64 { - let exactValue = Double(delta) * sensitivity + residual - guard exactValue.isFinite, - exactValue > Double(Int64.min), - exactValue < Double(Int64.max) else { - residual = 0 - return delta - } - - let scaledValue = Int64(exactValue.rounded(.toNearestOrAwayFromZero)) - residual = exactValue - Double(scaledValue) - return scaledValue - } -} - -struct SidebarSpacePagerPhysicalGestureGate { - private(set) var ignoresChangedEvents = false - - mutating func begin() { - ignoresChangedEvents = false - } - - mutating func ignoreChangedEventsUntilNextGesture() { - ignoresChangedEvents = true - } - - mutating func end() { - ignoresChangedEvents = false - } -} - -struct SidebarSpacePagerFixedChromeTarget { - static func pageID( - visibleFractionalPageIndex: CGFloat, - gestureStartFractionalPageIndex: CGFloat?, - gestureSourcePageIndex: Int? = nil, - rawGestureDisplacementX: CGFloat?, - pageWidth: CGFloat, - pages: [SidebarSpacePagerPageSnapshot] - ) -> SidebarSpacePagerPageID? { - guard !pages.isEmpty, - visibleFractionalPageIndex.isFinite, - pageWidth.isFinite, - pageWidth > 0 else { - return nil - } - - let gestureOrigin: CGFloat - if let gestureStartFractionalPageIndex, - gestureStartFractionalPageIndex.isFinite { - gestureOrigin = gestureStartFractionalPageIndex - } else { - gestureOrigin = visibleFractionalPageIndex.rounded() - } - - let pageIndex = SidebarSpacePagerSnap.targetPageIndex( - originalOffsetX: gestureOrigin * pageWidth, - proposedOffsetX: visibleFractionalPageIndex * pageWidth, - gestureStartFractionalPageIndex: gestureOrigin, - gestureSourcePageIndex: gestureSourcePageIndex, - visibleFractionalPageIndex: visibleFractionalPageIndex, - rawGestureDisplacementX: rawGestureDisplacementX, - velocityX: 0, - pageWidth: pageWidth, - pageCount: pages.count - ) - return pages[pageIndex].id - } -} - -struct SidebarSpacePagerChrome { - static func shouldDeferSettledStyle( - from currentPageID: SidebarSpacePagerPageID?, - to targetPageID: SidebarSpacePagerPageID? - ) -> Bool { - guard let targetPageID else { - return false - } - - return currentPageID != targetPageID - } - - static func theme( - for pageID: SidebarSpacePagerPageID?, - in pages: [SidebarSpacePagerPageSnapshot] - ) -> SidebarChromeTheme? { - guard let pageID else { - return nil - } - - return pages.first(where: { $0.id == pageID })?.chromeTheme - } - - static func color( - for pageID: SidebarSpacePagerPageID?, - in pages: [SidebarSpacePagerPageSnapshot] - ) -> SidebarChromeColor? { - theme(for: pageID, in: pages).map(SidebarChromeColor.init(theme:)) - } - - static func liveStyle( - for pageID: SidebarSpacePagerPageID?, - in pages: [SidebarSpacePagerPageSnapshot] - ) -> SidebarChromeLiveStyle? { - theme(for: pageID, in: pages).map(SidebarChromeLiveStyle.init(theme:)) - } - - static func liveColor( - at fractionalPageIndex: CGFloat, - in pages: [SidebarSpacePagerPageSnapshot] - ) -> SidebarChromeColor? { - liveColor( - at: fractionalPageIndex, - themes: pages.map(\.chromeTheme) - ) - } - - static func liveColor( - at fractionalPageIndex: CGFloat, - themes: [SidebarChromeTheme] - ) -> SidebarChromeColor? { - SidebarChromeColor.interpolated( - themes: themes, - fractionalIndex: Double(fractionalPageIndex) - ) - } - - static func liveStyle( - at fractionalPageIndex: CGFloat, - themes: [SidebarChromeTheme] - ) -> SidebarChromeLiveStyle? { - SidebarChromeLiveStyle.interpolated( - themes: themes, - fractionalIndex: Double(fractionalPageIndex) - ) - } - - static func liveStyle( - at fractionalPageIndex: CGFloat, - styles: [SidebarChromeLiveStyle] - ) -> SidebarChromeLiveStyle? { - SidebarChromeLiveStyle.interpolated( - styles: styles, - fractionalIndex: Double(fractionalPageIndex) - ) - } -} - -struct SidebarSpacePagerSelection { - static func committedPageID( - scrollPositionPageID: SidebarSpacePagerPageID?, - selectedPageID: SidebarSpacePagerPageID?, - pageIDs: [SidebarSpacePagerPageID] - ) -> SidebarSpacePagerPageID? { - guard let scrollPositionPageID, - scrollPositionPageID != selectedPageID, - pageIDs.contains(scrollPositionPageID) else { - return nil - } - - return scrollPositionPageID - } -} - -struct SidebarSpacePagerPreview { - static func spaceID( - for targetPageID: SidebarSpacePagerPageID, - selectedPageID: SidebarSpacePagerPageID? - ) -> SpaceID? { - guard targetPageID != selectedPageID, - case .space(let spaceID) = targetPageID else { - return nil - } - - return spaceID - } -} - -private struct SidebarAdjacentPageScrollTargetBehavior: ScrollTargetBehavior { - let pageCount: Int - let geometryTracker: SidebarSpacePagerGeometryTracker - - func updateTarget(_ target: inout ScrollTarget, context: TargetContext) { - guard context.axes.contains(.horizontal), - pageCount > 0 else { - return - } - - let pageWidth = context.containerSize.width - guard pageWidth.isFinite, - pageWidth > 0 else { - return - } - - let targetPageIndex: Int - if geometryTracker.acceptsDirectionalSnap { - targetPageIndex = SidebarSpacePagerSnap.targetPageIndex( - originalOffsetX: context.originalTarget.rect.minX, - proposedOffsetX: target.rect.minX, - gestureStartFractionalPageIndex: geometryTracker.gestureStartFractionalPageIndex, - gestureSourcePageIndex: geometryTracker.gestureSourcePageIndex, - visibleFractionalPageIndex: geometryTracker.visibleFractionalPageIndex, - rawGestureDisplacementX: geometryTracker.rawGestureDisplacementX, - velocityX: context.velocity.dx, - pageWidth: pageWidth, - pageCount: pageCount - ) - } else { - // SwiftUI also asks target behaviors to resolve container-size - // changes. Keep those updates page-aligned without interpreting a - // resize or programmatic animation as a directional gesture. - targetPageIndex = SidebarSpacePagerSnap.nearestPageIndex( - offsetX: target.rect.minX, - pageWidth: pageWidth, - pageCount: pageCount - ) - } - - geometryTracker.recordResolvedTargetPageIndex(targetPageIndex) - target.rect.origin.x = CGFloat(targetPageIndex) * pageWidth - target.anchor = .topLeading - } -} - -struct SidebarSpacePagerSnap { - static func targetPageIndex( - originalOffsetX: CGFloat, - proposedOffsetX: CGFloat, - gestureStartFractionalPageIndex: CGFloat? = nil, - gestureSourcePageIndex: Int? = nil, - visibleFractionalPageIndex: CGFloat?, - rawGestureDisplacementX: CGFloat? = nil, - velocityX: CGFloat, - pageWidth: CGFloat, - pageCount: Int - ) -> Int { - guard pageCount > 0, - pageWidth.isFinite, - pageWidth > 0 else { - return 0 - } - - let originalPagePosition = finiteOffset(originalOffsetX) / pageWidth - let gestureStartPagePosition = finiteValue(gestureStartFractionalPageIndex) - ?? originalPagePosition - let currentPageIndex = clampedPageIndex( - gestureSourcePageIndex ?? Int(gestureStartPagePosition.rounded()), - pageCount: pageCount - ) - let proposedDirection: Int? - if originalOffsetX.isFinite, proposedOffsetX.isFinite { - let proposedPagePosition = proposedOffsetX / pageWidth - proposedDirection = pageDirection(from: proposedPagePosition - gestureStartPagePosition) - } else { - proposedDirection = nil - } - - let visibleDirection: Int? - if let visibleFractionalPageIndex, - visibleFractionalPageIndex.isFinite { - visibleDirection = pageDirection( - from: visibleFractionalPageIndex - gestureStartPagePosition - ) - } else { - visibleDirection = nil - } - - let rawGestureDirection: Int? - if let rawGestureDisplacementX, - rawGestureDisplacementX.isFinite { - rawGestureDirection = pageDirection(from: rawGestureDisplacementX / pageWidth) - } else { - rawGestureDirection = nil - } - - // A short release can be quantized back to its origin before this hook - // runs. Prefer SwiftUI's proposed direction, then a meaningful release - // reversal, and finally recover a slow drag from its unscaled intent. - let pageStep = proposedDirection - ?? velocityDirection(from: velocityX) - ?? rawGestureDirection - ?? visibleDirection - ?? 0 - return clampedPageIndex(currentPageIndex + pageStep, pageCount: pageCount) - } - - static func nearestPageIndex( - offsetX: CGFloat, - pageWidth: CGFloat, - pageCount: Int - ) -> Int { - guard pageCount > 0, - pageWidth.isFinite, - pageWidth > 0 else { - return 0 - } - - return clampedPageIndex( - Int((finiteOffset(offsetX) / pageWidth).rounded()), - pageCount: pageCount - ) - } - - private static func finiteOffset(_ offset: CGFloat) -> CGFloat { - offset.isFinite ? offset : 0 - } - - private static func finiteValue(_ value: CGFloat?) -> CGFloat? { - guard let value, - value.isFinite else { - return nil - } - - return value - } - - private static func pageDirection(from delta: CGFloat) -> Int? { - guard delta.isFinite, - abs(delta) >= SidebarSpacePagerMetrics.directionalDistanceThresholdInPages else { - return nil - } - - return delta > 0 ? 1 : -1 - } - - private static func velocityDirection(from velocityX: CGFloat) -> Int? { - guard velocityX.isFinite, - abs(velocityX) >= SidebarSpacePagerMetrics.directionalVelocityThreshold else { - return nil - } - - return velocityX > 0 ? 1 : -1 - } - - private static func clampedPageIndex(_ pageIndex: Int, pageCount: Int) -> Int { - min(max(pageIndex, 0), pageCount - 1) - } -} - -struct SidebarSpacePagerFocus { - static func focusedTabID( - for space: BrowserSpace, - folders: [BrowserFolder], - tabsByID: [TabID: BrowserTab] - ) -> TabID? { - BrowserSpaceFocusedTabResolver.focusedTabID(for: space, folders: folders, tabsByID: tabsByID) - } - - static func isFocused(tabID: TabID, focusedTabID: TabID?) -> Bool { - tabID == focusedTabID - } -} - -struct SidebarSpacePageSnapshotBuilder { - static func spacePages( - activeSpaces: [BrowserSpace], - folders: [BrowserFolder], - tabs: [BrowserTab], - liveSessionTabIDs: Set = [] - ) -> [SidebarSpacePageSnapshot] { - let pageSpaceIDs = Set(activeSpaces.map(\.id)) - let foldersByID = Dictionary( - uniqueKeysWithValues: folders - .lazy - .filter { pageSpaceIDs.contains($0.parentSpaceID) } - .map { ($0.id, $0) } - ) - let foldersBySpaceID = Dictionary(grouping: foldersByID.values, by: \.parentSpaceID) - let directTabIDs = activeSpaces.flatMap { space in - space.favoriteTabIDs + space.pinnedTabIDs + space.regularTabIDs - } - let folderTabIDs = foldersByID.values.flatMap(\.tabIDs) - let visibleTabIDs = Set(directTabIDs + folderTabIDs) - let tabsByID = Dictionary( - uniqueKeysWithValues: tabs - .lazy - .filter { visibleTabIDs.contains($0.id) } - .map { ($0.id, $0) } - ) - - return activeSpaces.indices.map { index in - let space = activeSpaces[index] - let focusedTabID = SidebarSpacePagerFocus.focusedTabID( - for: space, - folders: foldersBySpaceID[space.id, default: []], - tabsByID: tabsByID - ) - - return SidebarSpacePageSnapshot( - index: index, - space: space, - favoriteTabs: tabItems( - for: space.favoriteTabIDs, - focusedTabID: focusedTabID, - tabsByID: tabsByID, - liveSessionTabIDs: liveSessionTabIDs - ), - pinnedTabs: tabItems( - for: space.pinnedTabIDs, - focusedTabID: focusedTabID, - tabsByID: tabsByID, - liveSessionTabIDs: liveSessionTabIDs - ), - folders: folderItems( - for: space.folderIDs, - focusedTabID: focusedTabID, - foldersByID: foldersByID, - tabsByID: tabsByID, - liveSessionTabIDs: liveSessionTabIDs - ), - regularTabs: tabItems( - for: space.regularTabIDs, - focusedTabID: focusedTabID, - tabsByID: tabsByID, - liveSessionTabIDs: liveSessionTabIDs - ) - ) - } - } - - private static func tabItems( - for ids: [TabID], - focusedTabID: TabID?, - tabsByID: [TabID: BrowserTab], - liveSessionTabIDs: Set - ) -> [SidebarTabItemSnapshot] { - let orderedTabs = ids.compactMap { tabsByID[$0] } - return orderedTabs.enumerated().map { index, tab in - SidebarTabItemSnapshot( - tab: tab, - isSelected: SidebarSpacePagerFocus.isFocused(tabID: tab.id, focusedTabID: focusedTabID), - hasLiveSession: liveSessionTabIDs.contains(tab.id), - canMoveUp: index > 0, - canMoveDown: index < orderedTabs.count - 1 - ) - } - } - - private static func folderItems( - for ids: [FolderID], - focusedTabID: TabID?, - foldersByID: [FolderID: BrowserFolder], - tabsByID: [TabID: BrowserTab], - liveSessionTabIDs: Set - ) -> [SidebarFolderItemSnapshot] { - ids.compactMap { id in - guard let folder = foldersByID[id] else { - return nil - } - - return SidebarFolderItemSnapshot( - folder: folder, - tabs: tabItems( - for: folder.tabIDs, - focusedTabID: focusedTabID, - tabsByID: tabsByID, - liveSessionTabIDs: liveSessionTabIDs - ), - childFolders: folderItems( - for: folder.childFolderIDs, - focusedTabID: focusedTabID, - foldersByID: foldersByID, - tabsByID: tabsByID, - liveSessionTabIDs: liveSessionTabIDs - ) - ) - } - } -} - private enum SidebarActivityMode: CaseIterable, Identifiable { case history case downloads @@ -4699,299 +4053,6 @@ private struct SidebarSpacePageView: View, Equatable { } } -private struct SidebarSpacePagerScrollInputScalingInstaller: NSViewRepresentable { - let sensitivity: CGFloat - let pageWidth: CGFloat - let geometryTracker: SidebarSpacePagerGeometryTracker - - func makeNSView(context: Context) -> SidebarSpacePagerScrollInputScalingView { - let view = SidebarSpacePagerScrollInputScalingView() - view.geometryTracker = geometryTracker - view.sensitivity = sensitivity - view.pageWidth = pageWidth - return view - } - - func updateNSView(_ nsView: SidebarSpacePagerScrollInputScalingView, context: Context) { - nsView.geometryTracker = geometryTracker - nsView.sensitivity = sensitivity - nsView.pageWidth = pageWidth - nsView.installIfNeeded() - } - - static func dismantleNSView( - _ nsView: SidebarSpacePagerScrollInputScalingView, - coordinator: () - ) { - nsView.tearDown() - } -} - -private final class SidebarSpacePagerScrollInputScalingView: NSView { - weak var geometryTracker: SidebarSpacePagerGeometryTracker? - var sensitivity: CGFloat = 1 - var pageWidth: CGFloat = 0 { - didSet { - guard oldValue > 0, - abs(oldValue - pageWidth) > 0.5 else { - return - } - - ignoreRemainderOfPhysicalGesture() - geometryTracker?.cancelDirectionalSnap() - } - } - - private weak var configuredScrollView: NSScrollView? - private var eventMonitor: Any? - private var deferredInstallationIsScheduled = false - private var ownsPhysicalGesture = false - private var gestureGate = SidebarSpacePagerPhysicalGestureGate() - private var inputScalingState = SidebarSpacePagerScrollInputScalingState() - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - installIfNeeded() - } - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - installIfNeeded() - } - - override func viewWillMove(toWindow newWindow: NSWindow?) { - super.viewWillMove(toWindow: newWindow) - if newWindow == nil { - tearDown() - } - } - - override func layout() { - super.layout() - installIfNeeded() - } - - deinit { - MainActor.assumeIsolated { - tearDown() - } - } - - func installIfNeeded() { - guard window != nil else { - return - } - - let candidateScrollView = nearestScrollView - if let configuredScrollView, - configuredScrollView === candidateScrollView { - installEventMonitorIfNeeded() - return - } - - let replacedConfiguredScrollView = configuredScrollView != nil - stopObservingConfiguredScrollView() - if replacedConfiguredScrollView { - ignoreRemainderOfPhysicalGesture() - geometryTracker?.cancelDirectionalSnap() - } else { - resetLocalGestureState() - } - - guard let candidateScrollView else { - scheduleDeferredInstallation() - return - } - - configuredScrollView = candidateScrollView - NotificationCenter.default.addObserver( - self, - selector: #selector(scrollViewDidEndLiveScroll(_:)), - name: NSScrollView.didEndLiveScrollNotification, - object: candidateScrollView - ) - installEventMonitorIfNeeded() - } - - func tearDown() { - stopObservingConfiguredScrollView() - removeEventMonitor() - resetLocalGestureState() - deferredInstallationIsScheduled = false - } - - private var nearestScrollView: NSScrollView? { - var candidate: NSView? = self - while let current = candidate { - if let scrollView = current as? NSScrollView { - return scrollView - } - candidate = current.superview - } - - return enclosingScrollView - } - - private func scheduleDeferredInstallation() { - guard !deferredInstallationIsScheduled else { - return - } - - deferredInstallationIsScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { - return - } - - self.deferredInstallationIsScheduled = false - self.installIfNeeded() - } - } - - private func installEventMonitorIfNeeded() { - guard eventMonitor == nil else { - return - } - - eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in - self?.eventForPagerInput(from: event) ?? event - } - } - - private func removeEventMonitor() { - if let eventMonitor { - NSEvent.removeMonitor(eventMonitor) - self.eventMonitor = nil - } - } - - private func stopObservingConfiguredScrollView() { - if let configuredScrollView { - NotificationCenter.default.removeObserver( - self, - name: NSScrollView.didEndLiveScrollNotification, - object: configuredScrollView - ) - } - configuredScrollView = nil - } - - private func eventForPagerInput(from event: NSEvent) -> NSEvent { - guard let scrollView = configuredScrollView, - event.window === scrollView.window else { - return event - } - - if !event.momentumPhase.isEmpty { - resetLocalGestureState() - return event - } - - let phase = event.phase - guard !phase.isEmpty else { - resetLocalGestureState() - return event - } - guard event.hasPreciseScrollingDeltas else { - resetLocalGestureState() - return event - } - - if phase.contains(.mayBegin) || phase.contains(.began) { - beginGestureIfInsidePager(event, scrollView: scrollView) - } - - if phase.contains(.ended) || phase.contains(.cancelled) { - resetLocalGestureState() - return event - } - - guard !gestureGate.ignoresChangedEvents else { - return event - } - - guard phase.contains(.began) || phase.contains(.changed) else { - return event - } - - if !ownsPhysicalGesture { - guard phase.contains(.changed), - eventIsInsidePager(event, scrollView: scrollView) else { - return event - } - - beginGesture() - } - - guard ownsPhysicalGesture, - let scaledEvent = inputScalingState.scaledEvent( - from: event, - sensitivity: sensitivity - ) else { - return event - } - - geometryTracker?.rawGestureDisplacementX = - inputScalingState.cumulativeRawDisplacementX - - // Scale before NSScrollView sees the event. The previous implementation - // let AppKit scroll at full strength and then corrected the clip view, - // forcing two pager layout/compositing passes per trackpad sample. - // Momentum and phase-ended events return above unchanged, preserving the - // native release velocity and adjacent-page spring. - return scaledEvent - } - - private func beginGestureIfInsidePager(_ event: NSEvent, scrollView: NSScrollView) { - guard eventIsInsidePager(event, scrollView: scrollView) else { - resetLocalGestureState() - return - } - - beginGesture() - } - - private func beginGesture() { - gestureGate.begin() - ownsPhysicalGesture = true - inputScalingState.reset() - // AppKit exposes a new physical trackpad gesture even when SwiftUI keeps - // the scroll phase in `decelerating`. Capture that boundary directly so - // a rapid follow-up swipe gets its own origin and advances from the - // previous gesture's resolved destination. - geometryTracker?.beginPhysicalGesture() - } - - private func eventIsInsidePager(_ event: NSEvent, scrollView: NSScrollView) -> Bool { - let localPoint = scrollView.convert(event.locationInWindow, from: nil) - return scrollView.bounds.contains(localPoint) - } - - @objc - private func scrollViewDidEndLiveScroll(_ notification: Notification) { - guard notification.object as? NSScrollView === configuredScrollView else { - return - } - - resetLocalGestureState() - } - - private func resetLocalGestureState() { - gestureGate.end() - clearLocalGestureState() - } - - private func ignoreRemainderOfPhysicalGesture() { - gestureGate.ignoreChangedEventsUntilNextGesture() - clearLocalGestureState() - } - - private func clearLocalGestureState() { - ownsPhysicalGesture = false - inputScalingState.reset() - } -} - private struct ScrollIndicatorHiderOverlay: View { var axes: ScrollIndicatorHiderAxes = .all @@ -5376,11 +4437,3 @@ private struct SidebarFolderNodeView: View { } } } - -enum SidebarSpacePagerMetrics { - static let selectionAnimation: Animation = .smooth(duration: 0.18, extraBounce: 0) - static let fixedChromeThemeAnimation: Animation = .smooth(duration: 0.18, extraBounce: 0) - static let activeScrollSensitivity: CGFloat = 0.35 - static let directionalDistanceThresholdInPages: CGFloat = 0.0025 - static let directionalVelocityThreshold: CGFloat = 20 -} diff --git a/Tests/MeridianBrowserTests/SidebarSpacePagerGestureTests.swift b/Tests/MeridianBrowserTests/SidebarSpacePagerGestureTests.swift new file mode 100644 index 0000000..3c91970 --- /dev/null +++ b/Tests/MeridianBrowserTests/SidebarSpacePagerGestureTests.swift @@ -0,0 +1,264 @@ +import Foundation +@testable import MeridianCore +import XCTest + +final class SidebarSpacePagerGestureTests: XCTestCase { + func testPhysicalGestureLifecycleIssuesMonotonicTokens() throws { + var lifecycle = SidebarSpacePagerPhysicalGestureLifecycle() + + let firstID = lifecycle.begin() + XCTAssertEqual(lifecycle.activeGestureID, firstID) + XCTAssertEqual( + try XCTUnwrap(lifecycle.finish(for: .physicalEnded)), + firstID + ) + XCTAssertFalse(lifecycle.isActive) + + let secondID = lifecycle.begin() + XCTAssertGreaterThan(secondID, firstID) + XCTAssertEqual( + try XCTUnwrap(lifecycle.finish(for: .physicalCancelled)), + secondID + ) + } + + func testPhysicalEventBoundariesEndPhysicalTrackpadGesture() throws { + let reasons: [SidebarSpacePagerPhysicalGestureEndReason] = [ + .physicalEnded, + .physicalCancelled, + .momentumBegan, + .unphasedInput + ] + + for reason in reasons { + var lifecycle = SidebarSpacePagerPhysicalGestureLifecycle() + let gestureID = lifecycle.begin() + + XCTAssertEqual( + try XCTUnwrap(lifecycle.finish(for: reason)), + gestureID + ) + XCTAssertFalse(lifecycle.isActive) + } + } + + func testAdjustedDeltaHasOneDeterministicDirectionContract() { + XCTAssertEqual( + SidebarSpacePagerHorizontalIntent.intent( + forAdjustedDisplacement: -3, + minimumDisplacement: 2 + ), + .backward + ) + XCTAssertEqual( + SidebarSpacePagerHorizontalIntent.intent( + forAdjustedDisplacement: 3, + minimumDisplacement: 2 + ), + .forward + ) + XCTAssertNil(SidebarSpacePagerHorizontalIntent.intent( + forAdjustedDisplacement: 1, + minimumDisplacement: 2 + )) + } + + func testAppKitWheelDirectionRoutesForwardToCreationAndBackwardToPaging() throws { + let forwardInput = try XCTUnwrap(SidebarSpacePagerHorizontalInputSample( + scrollingDeltaX: -3 + )) + let backwardInput = try XCTUnwrap(SidebarSpacePagerHorizontalInputSample( + scrollingDeltaX: 3 + )) + + var forwardSession = SidebarSpaceCreationGestureSession() + forwardSession.begin(canPullForward: true) + guard case .pulling = forwardSession.routeAdjustedDelta( + forwardInput.adjustedDisplacementX, + pageWidth: 300 + ) else { + return XCTFail("Forward AppKit input must enter creation") + } + + var backwardSession = SidebarSpaceCreationGestureSession() + backwardSession.begin(canPullForward: true) + XCTAssertEqual( + backwardSession.routeAdjustedDelta( + backwardInput.adjustedDisplacementX, + pageWidth: 300 + ), + .pagingBackward + ) + } + + func testForwardWheelInputCanCreateUnlimitedConsecutiveSpaces() throws { + var pageCount = 3 + var selectedPageIndex = pageCount - 1 + let initialForwardInput = try XCTUnwrap( + SidebarSpacePagerHorizontalInputSample(scrollingDeltaX: -3) + ) + let remainingForwardInput = try XCTUnwrap( + SidebarSpacePagerHorizontalInputSample( + scrollingDeltaX: + -(SidebarSpacePagerMetrics.creationPullThreshold - 3) + ) + ) + + for _ in 0..<12 { + XCTAssertEqual(selectedPageIndex, pageCount - 1) + + var session = SidebarSpaceCreationGestureSession() + session.begin(canPullForward: true) + guard case .pulling = session.routeAdjustedDelta( + initialForwardInput.adjustedDisplacementX, + pageWidth: 300 + ) else { + return XCTFail("Every final page must allow another creation pull") + } + guard case .pulling(let update) = session.routeAdjustedDelta( + remainingForwardInput.adjustedDisplacementX, + pageWidth: 300 + ) else { + return XCTFail("Creation pull must stay forward") + } + XCTAssertEqual(update.progress, 1, accuracy: 0.0001) + XCTAssertEqual(session.finishPull(cancelled: false), .create) + + pageCount += 1 + selectedPageIndex = pageCount - 1 + } + + XCTAssertEqual(pageCount, 15) + XCTAssertEqual(selectedPageIndex, 14) + } + + func testNarratedLastSpaceSequenceRemainsDeterministicWhenRepeated() throws { + var session = SidebarSpaceCreationGestureSession() + + for _ in 0..<4 { + session.begin(canPullForward: true) + XCTAssertEqual( + session.routeAdjustedDelta(-3, pageWidth: 300), + .pagingBackward + ) + XCTAssertEqual( + session.routeAdjustedDelta(100, pageWidth: 300), + .pagingBackward + ) + session.end() + + session.begin(canPullForward: true) + guard case .pulling = session.routeAdjustedDelta(3, pageWidth: 300) else { + return XCTFail("Forward input from the final space must start creation") + } + guard case .pulling(let update) = session.routeAdjustedDelta( + SidebarSpacePagerMetrics.creationPullThreshold - 3, + pageWidth: 300 + ) else { + return XCTFail("Established creation must remain creation") + } + XCTAssertEqual(update.progress, 1, accuracy: 0.0001) + XCTAssertEqual(session.finishPull(cancelled: false), .create) + session.end() + } + + session.begin(canPullForward: true) + XCTAssertEqual( + session.routeAdjustedDelta(-3, pageWidth: 300), + .pagingBackward + ) + } + + func testBackwardInputRoutesBeforeAnyGeometryCanParticipate() { + var session = SidebarSpaceCreationGestureSession() + session.begin(canPullForward: true) + + XCTAssertEqual( + session.routeAdjustedDelta(-2, pageWidth: 300), + .pagingBackward + ) + XCTAssertFalse(session.isPulling) + XCTAssertEqual(session.finishPull(cancelled: false), .none) + } + + func testSubthresholdInputIsNotForcedIntoCreationOnRelease() { + var session = SidebarSpaceCreationGestureSession() + session.begin(canPullForward: true) + + XCTAssertEqual( + session.routeAdjustedDelta(1, pageWidth: 300), + .pending + ) + XCTAssertEqual(session.finishPull(cancelled: false), .none) + } + + func testCreationPullReversalCancelsForRemainderOfPhysicalGesture() { + var session = SidebarSpaceCreationGestureSession() + session.begin(canPullForward: true) + + guard case .pulling = session.routeAdjustedDelta(30, pageWidth: 300) else { + return XCTFail("Expected creation pull") + } + XCTAssertEqual( + session.routeAdjustedDelta(-31, pageWidth: 300), + .cancelled + ) + XCTAssertEqual( + session.routeAdjustedDelta(200, pageWidth: 300), + .cancelled + ) + XCTAssertTrue(session.isCancelled) + XCTAssertEqual(session.finishPull(cancelled: false), .cancel) + } + + func testSwiftUIIdleCannotClearAnActivePhysicalGesture() { + let tracker = SidebarSpacePagerGeometryTracker() + tracker.visibleFractionalPageIndex = 2 + tracker.beginPhysicalGesture(id: 1) + tracker.adjustedGestureDisplacementX = 12 + + tracker.transition(from: .interacting, to: .idle) + + XCTAssertEqual(tracker.activePhysicalGestureID, 1) + XCTAssertTrue(tracker.acceptsDirectionalSnap) + XCTAssertEqual(tracker.gestureStartFractionalPageIndex, 2) + XCTAssertEqual(tracker.adjustedGestureDisplacementX, 12) + + tracker.endPhysicalGesture(id: 1) + XCTAssertNil(tracker.activePhysicalGestureID) + XCTAssertFalse(tracker.acceptsDirectionalSnap) + } + + func testDeferredCompletionCannotEndANewerGesture() { + let tracker = SidebarSpacePagerGeometryTracker() + tracker.visibleFractionalPageIndex = 1 + tracker.beginPhysicalGesture(id: 7) + tracker.recordResolvedTargetPageIndex(2) + + tracker.visibleFractionalPageIndex = 1.2 + tracker.beginPhysicalGesture(id: 8) + tracker.endPhysicalGesture(id: 7) + + XCTAssertEqual(tracker.activePhysicalGestureID, 8) + XCTAssertTrue(tracker.acceptsDirectionalSnap) + XCTAssertEqual(tracker.gestureSourcePageIndex, 2) + + tracker.endPhysicalGesture(id: 8) + XCTAssertNil(tracker.activePhysicalGestureID) + } + + func testVisualLastPageCanEnableCreationWithoutSelectionState() { + XCTAssertTrue(SidebarSpaceCreationPullEligibility.canBegin( + creationIsAvailable: true, + scrollIsIdle: true, + currentOffsetX: 600, + lastPageOffsetX: 600 + )) + XCTAssertFalse(SidebarSpaceCreationPullEligibility.canBegin( + creationIsAvailable: false, + scrollIsIdle: true, + currentOffsetX: 600, + lastPageOffsetX: 600 + )) + } +} diff --git a/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift b/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift index 005dd7f..3df24e8 100644 --- a/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift +++ b/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift @@ -238,6 +238,88 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { )) } + func testAddressMorphPlanPreservesSharedPrefixAndSuffix() { + let plan = SidebarAddressMorphPlan( + sourceText: "https://one.example/page", + destinationText: "https://two.example/page" + ) + + XCTAssertEqual(plan.sharedPrefixCount, "https://".count) + XCTAssertEqual(plan.sharedSuffixCount, ".example/page".count) + XCTAssertEqual(plan.sourceChangedCount, 3) + XCTAssertEqual(plan.destinationChangedCount, 3) + } + + func testAddressGlyphMorphRendersExactSourceAtBeginning() { + let source = "https://one.example/page" + let destination = "https://two.example/page" + let plan = SidebarAddressMorphPlan( + sourceText: source, + destinationText: destination + ) + + for index in 0.. Int { var state = SidebarSpacePagerScrollInputScalingState() + var accumulator = SidebarSpacePagerHorizontalGestureAccumulator() + let input = try XCTUnwrap(SidebarSpacePagerHorizontalInputSample( + scrollingDeltaX: -1 + )) + accumulator.append(input) let scaledEvent = try XCTUnwrap(state.scaledEvent( - from: scrollEvent(horizontalDelta: 1), + from: scrollEvent(horizontalDelta: -1), sensitivity: sensitivity )) - let displayedOffset = 200 + scaledEvent.scrollingDeltaX + let displayedOffset = 200 - scaledEvent.scrollingDeltaX return SidebarSpacePagerSnap.targetPageIndex( originalOffsetX: 200, proposedOffsetX: 200, visibleFractionalPageIndex: displayedOffset / 200, - rawGestureDisplacementX: state.cumulativeRawDisplacementX, + adjustedGestureDisplacementX: + accumulator.cumulativeAdjustedDisplacementX, velocityX: 0, pageWidth: 200, pageCount: 5 @@ -584,7 +850,7 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { originalOffsetX: 200, proposedOffsetX: 200, visibleFractionalPageIndex: 1.01, - rawGestureDisplacementX: 20, + adjustedGestureDisplacementX: 20, velocityX: -5_000, pageWidth: 200, pageCount: 5 @@ -599,7 +865,7 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { originalOffsetX: 200, proposedOffsetX: 201, visibleFractionalPageIndex: 0.99, - rawGestureDisplacementX: -20, + adjustedGestureDisplacementX: -20, velocityX: -5_000, pageWidth: 200, pageCount: 5 @@ -691,7 +957,7 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { tracker.transition(from: .idle, to: .tracking) XCTAssertTrue(tracker.acceptsDirectionalSnap) XCTAssertEqual(tracker.gestureStartFractionalPageIndex, 1.75) - tracker.rawGestureDisplacementX = 12 + tracker.adjustedGestureDisplacementX = 12 tracker.visibleFractionalPageIndex = 1.8 tracker.transition(from: .tracking, to: .interacting) @@ -702,12 +968,12 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { tracker.transition(from: .decelerating, to: .interacting) XCTAssertEqual(tracker.gestureStartFractionalPageIndex, 1.8) - XCTAssertNil(tracker.rawGestureDisplacementX) + XCTAssertNil(tracker.adjustedGestureDisplacementX) tracker.transition(from: .interacting, to: .idle) XCTAssertFalse(tracker.acceptsDirectionalSnap) XCTAssertNil(tracker.gestureStartFractionalPageIndex) - XCTAssertNil(tracker.rawGestureDisplacementX) + XCTAssertNil(tracker.adjustedGestureDisplacementX) tracker.transition(from: .idle, to: .tracking) tracker.transition(from: .tracking, to: .animating) @@ -724,14 +990,14 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { tracker.visibleFractionalPageIndex = 2 tracker.transition(from: .idle, to: .tracking) - tracker.rawGestureDisplacementX = 18 + tracker.adjustedGestureDisplacementX = 18 tracker.visibleFractionalPageIndex = 2.04 tracker.transition(from: .tracking, to: .interacting) tracker.transition(from: .interacting, to: .animating) XCTAssertTrue(tracker.acceptsDirectionalSnap) XCTAssertEqual(tracker.gestureStartFractionalPageIndex, 2) - XCTAssertEqual(tracker.rawGestureDisplacementX, 18) + XCTAssertEqual(tracker.adjustedGestureDisplacementX, 18) } func testPagerAnchorsGestureInterruptingAnimationToPreviousDestination() { @@ -747,7 +1013,7 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { XCTAssertEqual(tracker.gestureStartFractionalPageIndex, 1.2) XCTAssertEqual(tracker.gestureSourcePageIndex, 2) - XCTAssertNil(tracker.rawGestureDisplacementX) + XCTAssertNil(tracker.adjustedGestureDisplacementX) XCTAssertTrue(tracker.acceptsDirectionalSnap) } @@ -755,12 +1021,12 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { let tracker = SidebarSpacePagerGeometryTracker() tracker.visibleFractionalPageIndex = 1 - tracker.beginPhysicalGesture() + tracker.beginPhysicalGesture(id: 1) tracker.transition(from: .idle, to: .interacting) tracker.recordResolvedTargetPageIndex(2) tracker.visibleFractionalPageIndex = 1.2 - tracker.beginPhysicalGesture() + tracker.beginPhysicalGesture(id: 2) XCTAssertEqual(tracker.gestureStartFractionalPageIndex, 1.2) XCTAssertEqual(tracker.gestureSourcePageIndex, 2) @@ -773,7 +1039,7 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { gestureStartFractionalPageIndex: tracker.gestureStartFractionalPageIndex, gestureSourcePageIndex: tracker.gestureSourcePageIndex, visibleFractionalPageIndex: 1.205, - rawGestureDisplacementX: 1, + adjustedGestureDisplacementX: 1, velocityX: 0, pageWidth: 200, pageCount: 5 @@ -790,7 +1056,7 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { gestureStartFractionalPageIndex: 1.2, gestureSourcePageIndex: 2, visibleFractionalPageIndex: 1.205, - rawGestureDisplacementX: 1, + adjustedGestureDisplacementX: 1, velocityX: 0, pageWidth: 200, pageCount: 5 @@ -804,7 +1070,7 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { gestureStartFractionalPageIndex: 1.2, gestureSourcePageIndex: 2, visibleFractionalPageIndex: 1.195, - rawGestureDisplacementX: -1, + adjustedGestureDisplacementX: -1, velocityX: 0, pageWidth: 200, pageCount: 5 @@ -821,7 +1087,7 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { XCTAssertFalse(tracker.acceptsDirectionalSnap) XCTAssertNil(tracker.gestureStartFractionalPageIndex) - XCTAssertNil(tracker.rawGestureDisplacementX) + XCTAssertNil(tracker.adjustedGestureDisplacementX) } func testPagerCanMoveInwardFromBothEdges() { From a22f1971267cb939871e0efad44204236a10b330 Mon Sep 17 00:00:00 2001 From: Andres Date: Thu, 23 Jul 2026 09:14:34 -0400 Subject: [PATCH 2/4] Allow creation during interrupted space arrivals --- .../Sidebar/SidebarSpaceCreationPull.swift | 18 ++-- .../Sidebar/SidebarSpacePagerInput.swift | 37 ++++---- .../Sidebar/SidebarSpacePagerPolicy.swift | 30 +++++-- .../Views/Sidebar/SidebarView.swift | 31 +++++-- .../SidebarSpacePagerGestureTests.swift | 88 ++++++++++++++++++- .../SidebarSpacePagerSelectionTests.swift | 24 +++-- 6 files changed, 183 insertions(+), 45 deletions(-) diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarSpaceCreationPull.swift b/Sources/MeridianCore/Views/Sidebar/SidebarSpaceCreationPull.swift index 565d3e7..443489b 100644 --- a/Sources/MeridianCore/Views/Sidebar/SidebarSpaceCreationPull.swift +++ b/Sources/MeridianCore/Views/Sidebar/SidebarSpaceCreationPull.swift @@ -3,19 +3,25 @@ import SwiftUI struct SidebarSpaceCreationPullEligibility { static func canBegin( creationIsAvailable: Bool, - scrollIsIdle: Bool, + gestureOrigin: SidebarSpacePagerPhysicalGestureOrigin, currentOffsetX: CGFloat, - lastPageOffsetX: CGFloat + lastPageOffsetX: CGFloat, + lastPageIndex: Int ) -> Bool { guard creationIsAvailable, - scrollIsIdle, currentOffsetX.isFinite, - lastPageOffsetX.isFinite else { + lastPageOffsetX.isFinite, + lastPageIndex >= 0 else { return false } - return abs(currentOffsetX - lastPageOffsetX) - <= SidebarSpacePagerMetrics.creationSettledOffsetTolerance + if gestureOrigin.anchoredPageIndex == lastPageIndex { + return true + } + + return gestureOrigin.scrollWasIdle + && abs(currentOffsetX - lastPageOffsetX) + <= SidebarSpacePagerMetrics.creationSettledOffsetTolerance } } diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerInput.swift b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerInput.swift index c9e13df..cf3a2ca 100644 --- a/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerInput.swift +++ b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerInput.swift @@ -364,26 +364,27 @@ final class SidebarSpacePagerScrollInputScalingView: NSView { suppressesCreationMomentum = false creationPullController?.returnToRest(animated: false) - let isSettledOnLastPage: Bool + // Use the same logical origin as normal paging. If this gesture + // interrupts an arrival animation, the previous resolved destination + // is authoritative even though the clip view has not reached it yet. + let gestureOrigin = geometryTracker?.beginPhysicalGesture(id: gestureID) + let canPullPastLastPage: Bool let currentOffsetX = configuredScrollView?.contentView.bounds.origin.x - if let currentOffsetX, - let lastPageOffsetX { - isSettledOnLastPage = SidebarSpaceCreationPullEligibility.canBegin( + if let gestureOrigin, + let currentOffsetX, + let lastPageOffsetX, + let lastPageIndex { + canPullPastLastPage = SidebarSpaceCreationPullEligibility.canBegin( creationIsAvailable: creationIsAvailable, - scrollIsIdle: geometryTracker?.scrollIsIdle == true, + gestureOrigin: gestureOrigin, currentOffsetX: currentOffsetX, - lastPageOffsetX: lastPageOffsetX + lastPageOffsetX: lastPageOffsetX, + lastPageIndex: lastPageIndex ) } else { - isSettledOnLastPage = false + canPullPastLastPage = false } - creationSession.begin(canPullForward: isSettledOnLastPage) - - // AppKit exposes a new physical trackpad gesture even when SwiftUI keeps - // the scroll phase in `decelerating`. Capture that boundary directly so - // a rapid follow-up swipe gets its own origin and advances from the - // previous gesture's resolved destination. - geometryTracker?.beginPhysicalGesture(id: gestureID) + creationSession.begin(canPullForward: canPullPastLastPage) } private func eventIsInsidePager(_ event: NSEvent, scrollView: NSScrollView) -> Bool { @@ -440,12 +441,16 @@ final class SidebarSpacePagerScrollInputScalingView: NSView { } private var lastPageOffsetX: CGFloat? { - guard pageCount > 0, + guard let lastPageIndex, pageWidth.isFinite, pageWidth > 0 else { return nil } - return CGFloat(pageCount - 1) * pageWidth + return CGFloat(lastPageIndex) * pageWidth + } + + private var lastPageIndex: Int? { + pageCount > 0 ? pageCount - 1 : nil } private func cancelCreationInteraction(animated: Bool) { diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift index f1ae0a2..a966607 100644 --- a/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift +++ b/Sources/MeridianCore/Views/Sidebar/SidebarSpacePagerPolicy.swift @@ -61,6 +61,11 @@ struct SidebarSpacePagerHorizontalGestureAccumulator: Equatable, Sendable { } } +struct SidebarSpacePagerPhysicalGestureOrigin: Equatable, Sendable { + let scrollWasIdle: Bool + let anchoredPageIndex: Int? +} + /// Non-observable gesture geometry shared by SwiftUI's phase callbacks and the /// scroll-target policy. Live samples never invalidate the pager view tree. final class SidebarSpacePagerGeometryTracker { @@ -103,9 +108,10 @@ final class SidebarSpacePagerGeometryTracker { acceptsDirectionalSnap = true case .animating: // Preserve the captured gesture origin through SwiftUI's alignment - // animation. Programmatic animations begin from idle and opt out. + // animation. Programmatic animations opt out of the active snap but + // retain their destination so a physical interruption can anchor to it. if oldPhase == .idle, activePhysicalGestureID == nil { - resetDirectionalSnap() + resetDirectionalSnap(preservingResolvedTarget: true) } case .idle: if activePhysicalGestureID == nil { @@ -119,10 +125,16 @@ final class SidebarSpacePagerGeometryTracker { resetDirectionalSnap() } - func beginPhysicalGesture(id: UInt64) { + @discardableResult + func beginPhysicalGesture(id: UInt64) -> SidebarSpacePagerPhysicalGestureOrigin { + let origin = SidebarSpacePagerPhysicalGestureOrigin( + scrollWasIdle: scrollIsIdle, + anchoredPageIndex: resolvedTargetPageIndex + ) activePhysicalGestureID = id creationPullLocksLastPage = false beginDirectionalSnap(sourcePageIndex: resolvedTargetPageIndex) + return origin } func endPhysicalGesture(id: UInt64) { @@ -148,12 +160,20 @@ final class SidebarSpacePagerGeometryTracker { resolvedTargetPageIndex = pageIndex } - private func resetDirectionalSnap() { + func prepareForProgrammaticNavigation(to pageIndex: Int) { + activePhysicalGestureID = nil + resetDirectionalSnap() + resolvedTargetPageIndex = pageIndex + } + + private func resetDirectionalSnap(preservingResolvedTarget: Bool = false) { acceptsDirectionalSnap = false gestureStartFractionalPageIndex = nil gestureSourcePageIndex = nil adjustedGestureDisplacementX = nil - resolvedTargetPageIndex = nil + if !preservingResolvedTarget { + resolvedTargetPageIndex = nil + } creationPullLocksLastPage = false } diff --git a/Sources/MeridianCore/Views/Sidebar/SidebarView.swift b/Sources/MeridianCore/Views/Sidebar/SidebarView.swift index 9bdc643..45ba29a 100644 --- a/Sources/MeridianCore/Views/Sidebar/SidebarView.swift +++ b/Sources/MeridianCore/Views/Sidebar/SidebarView.swift @@ -3070,7 +3070,7 @@ private struct SidebarSpacePagerView: View { if pageTravelIsRequired(to: request.pageID) { settledStyleAwaitsScrollIdle = true } - geometryTracker.cancelDirectionalSnap() + prepareGeometryTrackerForProgrammaticNavigation(to: request.pageID) if case .space = request.pageID { selectAuxiliaryPage(nil) } else { @@ -3289,22 +3289,26 @@ private struct SidebarSpacePagerView: View { } private func syncScrollPositionToSelection(animated: Bool) { - geometryTracker.cancelDirectionalSnap() - guard let selectedPageID, - scrollPositionPageID != selectedPageID else { + guard let selectedPageID else { + geometryTracker.cancelDirectionalSnap() return } - - if !animated, - let selectedPageIndex = snapshot.pages.firstIndex(where: { $0.id == selectedPageID }) { - geometryTracker.visibleFractionalPageIndex = CGFloat(selectedPageIndex) + guard scrollPositionPageID != selectedPageID else { + return } if animated { + prepareGeometryTrackerForProgrammaticNavigation(to: selectedPageID) withAnimation(SidebarSpacePagerMetrics.selectionAnimation) { scrollPositionPageID = selectedPageID } } else { + geometryTracker.cancelDirectionalSnap() + if let selectedPageIndex = snapshot.pages.firstIndex( + where: { $0.id == selectedPageID } + ) { + geometryTracker.visibleFractionalPageIndex = CGFloat(selectedPageIndex) + } scrollPositionPageID = selectedPageID } } @@ -3318,6 +3322,17 @@ private struct SidebarSpacePagerView: View { geometryTracker.visibleFractionalPageIndex = CGFloat(pageIndex) } + private func prepareGeometryTrackerForProgrammaticNavigation( + to pageID: SidebarSpacePagerPageID + ) { + guard let pageIndex = snapshot.pages.firstIndex(where: { $0.id == pageID }) else { + geometryTracker.cancelDirectionalSnap() + return + } + + geometryTracker.prepareForProgrammaticNavigation(to: pageIndex) + } + private func commitPageIfNeeded(_ pageID: SidebarSpacePagerPageID?) { guard let committedPageID = SidebarSpacePagerSelection.committedPageID( scrollPositionPageID: pageID, diff --git a/Tests/MeridianBrowserTests/SidebarSpacePagerGestureTests.swift b/Tests/MeridianBrowserTests/SidebarSpacePagerGestureTests.swift index 3c91970..07ea5c8 100644 --- a/Tests/MeridianBrowserTests/SidebarSpacePagerGestureTests.swift +++ b/Tests/MeridianBrowserTests/SidebarSpacePagerGestureTests.swift @@ -247,18 +247,98 @@ final class SidebarSpacePagerGestureTests: XCTestCase { XCTAssertNil(tracker.activePhysicalGestureID) } + func testCreationCanInterruptNaturalArrivalAtFinalPage() throws { + let tracker = SidebarSpacePagerGeometryTracker() + tracker.visibleFractionalPageIndex = 1 + tracker.beginPhysicalGesture(id: 1) + tracker.transition(from: .idle, to: .interacting) + tracker.recordResolvedTargetPageIndex(2) + tracker.transition(from: .interacting, to: .animating) + tracker.endPhysicalGesture(id: 1) + + tracker.visibleFractionalPageIndex = 1.35 + let origin = tracker.beginPhysicalGesture(id: 2) + + XCTAssertFalse(origin.scrollWasIdle) + XCTAssertEqual(origin.anchoredPageIndex, 2) + XCTAssertTrue(SidebarSpaceCreationPullEligibility.canBegin( + creationIsAvailable: true, + gestureOrigin: origin, + currentOffsetX: 405, + lastPageOffsetX: 600, + lastPageIndex: 2 + )) + + let forwardInput = try XCTUnwrap(SidebarSpacePagerHorizontalInputSample( + scrollingDeltaX: -3 + )) + var session = SidebarSpaceCreationGestureSession() + session.begin(canPullForward: true) + guard case .pulling = session.routeAdjustedDelta( + forwardInput.adjustedDisplacementX, + pageWidth: 300 + ) else { + return XCTFail("Forward interruption at the final target must create") + } + } + + func testCreationCanInterruptProgrammaticArrivalAtNewFinalPage() { + let tracker = SidebarSpacePagerGeometryTracker() + tracker.visibleFractionalPageIndex = 2 + tracker.prepareForProgrammaticNavigation(to: 3) + tracker.transition(from: .idle, to: .animating) + + tracker.visibleFractionalPageIndex = 2.2 + let origin = tracker.beginPhysicalGesture(id: 10) + + XCTAssertFalse(origin.scrollWasIdle) + XCTAssertEqual(origin.anchoredPageIndex, 3) + XCTAssertTrue(SidebarSpaceCreationPullEligibility.canBegin( + creationIsAvailable: true, + gestureOrigin: origin, + currentOffsetX: 660, + lastPageOffsetX: 900, + lastPageIndex: 3 + )) + } + + func testAnimationTowardNonfinalPageCannotEnableCreation() { + let tracker = SidebarSpacePagerGeometryTracker() + tracker.visibleFractionalPageIndex = 1 + tracker.prepareForProgrammaticNavigation(to: 2) + tracker.transition(from: .idle, to: .animating) + + let origin = tracker.beginPhysicalGesture(id: 20) + + XCTAssertFalse(SidebarSpaceCreationPullEligibility.canBegin( + creationIsAvailable: true, + gestureOrigin: origin, + currentOffsetX: 330, + lastPageOffsetX: 900, + lastPageIndex: 3 + )) + } + func testVisualLastPageCanEnableCreationWithoutSelectionState() { XCTAssertTrue(SidebarSpaceCreationPullEligibility.canBegin( creationIsAvailable: true, - scrollIsIdle: true, + gestureOrigin: SidebarSpacePagerPhysicalGestureOrigin( + scrollWasIdle: true, + anchoredPageIndex: nil + ), currentOffsetX: 600, - lastPageOffsetX: 600 + lastPageOffsetX: 600, + lastPageIndex: 2 )) XCTAssertFalse(SidebarSpaceCreationPullEligibility.canBegin( creationIsAvailable: false, - scrollIsIdle: true, + gestureOrigin: SidebarSpacePagerPhysicalGestureOrigin( + scrollWasIdle: true, + anchoredPageIndex: nil + ), currentOffsetX: 600, - lastPageOffsetX: 600 + lastPageOffsetX: 600, + lastPageIndex: 2 )) } } diff --git a/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift b/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift index 3df24e8..2866cb1 100644 --- a/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift +++ b/Tests/MeridianBrowserTests/SidebarSpacePagerSelectionTests.swift @@ -586,21 +586,33 @@ final class SidebarSpacePagerSelectionTests: XCTestCase { func testCreationPullBeginsOnlyFromSettledLastPage() { XCTAssertTrue(SidebarSpaceCreationPullEligibility.canBegin( creationIsAvailable: true, - scrollIsIdle: true, + gestureOrigin: SidebarSpacePagerPhysicalGestureOrigin( + scrollWasIdle: true, + anchoredPageIndex: nil + ), currentOffsetX: 800, - lastPageOffsetX: 800 + lastPageOffsetX: 800, + lastPageIndex: 4 )) XCTAssertFalse(SidebarSpaceCreationPullEligibility.canBegin( creationIsAvailable: true, - scrollIsIdle: true, + gestureOrigin: SidebarSpacePagerPhysicalGestureOrigin( + scrollWasIdle: true, + anchoredPageIndex: nil + ), currentOffsetX: 600, - lastPageOffsetX: 800 + lastPageOffsetX: 800, + lastPageIndex: 4 )) XCTAssertFalse(SidebarSpaceCreationPullEligibility.canBegin( creationIsAvailable: true, - scrollIsIdle: false, + gestureOrigin: SidebarSpacePagerPhysicalGestureOrigin( + scrollWasIdle: false, + anchoredPageIndex: nil + ), currentOffsetX: 800, - lastPageOffsetX: 800 + lastPageOffsetX: 800, + lastPageIndex: 4 )) } From 166c752e5b5c20937d7cf3ef565e721fda99491c Mon Sep 17 00:00:00 2001 From: Andres Date: Thu, 23 Jul 2026 10:47:45 -0400 Subject: [PATCH 3/4] Rebrand as Lumen Browser and polish space creation --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +- .github/ISSUE_TEMPLATE/feature_request.yml | 4 +- CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 4 +- DebugFixtures/download-context-menu.html | 2 +- Docs/Architecture.md | 4 +- Docs/ThreatModel.md | 6 +- Docs/WebKitLimitations.md | 14 +-- README.md | 18 +-- Resources/LumenIconSources/LumenDark.png | Bin 0 -> 1316844 bytes Resources/LumenIconSources/LumenLight.png | Bin 0 -> 1174981 bytes Resources/LumenLogo.icon/Assets/LumenDark.png | Bin 0 -> 1316844 bytes .../LumenLogo.icon/Assets/LumenLight.png | Bin 0 -> 1174981 bytes Resources/LumenLogo.icon/icon.json | 47 +++++++ Resources/MammothLogo.icon/Assets/Image.png | Bin 26907 -> 0 bytes .../MammothLogo.icon/Assets/mammoth_blue.png | Bin 23166 -> 0 bytes .../MammothLogo.icon/Assets/mammoth_lime.png | Bin 23273 -> 0 bytes .../MammothLogo.icon/Assets/mammoth_teal.png | Bin 23185 -> 0 bytes ...small-dock-mammoth-silhouette-with-eye.png | Bin 6112 -> 0 bytes Resources/MammothLogo.icon/icon.json | 119 ------------------ Resources/README.md | 5 +- SECURITY.md | 4 +- SUPPORT.md | 2 +- .../MeridianBrowser/MeridianBrowserApp.swift | 2 +- .../Models/ProfileIsolation.swift | 6 +- .../DownloadConfirmationRequest.swift | 2 +- .../Security/DownloadSafetyPolicy.swift | 2 +- .../Security/PasswordCredentialStore.swift | 9 +- .../Security/SitePermissionPolicy.swift | 2 +- .../Security/URLConfirmationRequest.swift | 4 +- .../SQLiteLocalHistoryPersistenceStore.swift | 23 ++-- .../SQLiteSessionPersistenceStore.swift | 23 ++-- .../Services/SessionIntegrityRepair.swift | 2 +- .../MeridianCore/Stores/BrowserStore.swift | 4 +- .../Views/Browser/ProfileManagementView.swift | 2 +- .../Views/BrowserWindowView.swift | 6 +- .../Sidebar/SidebarSpaceCreationPull.swift | 39 ++++-- Sources/MeridianCore/WebKit/WebViewHost.swift | 6 +- .../BrowserStoreTests.swift | 6 +- .../BrowserUserAgentTests.swift | 2 +- .../DownloadSafetyPolicyTests.swift | 6 +- .../NavigationFailureDiagnosticsTests.swift | 2 +- .../PasswordAutofillScriptTests.swift | 2 +- .../PasswordCredentialStoreTests.swift | 12 +- ...ofileIsolationWebKitIntegrationTests.swift | 2 +- .../SessionPersistenceBoundaryTests.swift | 2 +- .../SidebarSpacePagerSelectionTests.swift | 41 ++++++ .../SitePermissionPolicyTests.swift | 2 +- script/build_and_run.sh | 37 +++--- 49 files changed, 250 insertions(+), 231 deletions(-) create mode 100644 Resources/LumenIconSources/LumenDark.png create mode 100644 Resources/LumenIconSources/LumenLight.png create mode 100644 Resources/LumenLogo.icon/Assets/LumenDark.png create mode 100644 Resources/LumenLogo.icon/Assets/LumenLight.png create mode 100644 Resources/LumenLogo.icon/icon.json delete mode 100644 Resources/MammothLogo.icon/Assets/Image.png delete mode 100644 Resources/MammothLogo.icon/Assets/mammoth_blue.png delete mode 100644 Resources/MammothLogo.icon/Assets/mammoth_lime.png delete mode 100644 Resources/MammothLogo.icon/Assets/mammoth_teal.png delete mode 100644 Resources/MammothLogo.icon/Assets/small-dock-mammoth-silhouette-with-eye.png delete mode 100644 Resources/MammothLogo.icon/icon.json diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3c3e65b..bc4f685 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a reproducible Bare Browser bug. +description: Report a reproducible Lumen Browser bug. title: "[Bug]: " labels: - bug @@ -21,7 +21,7 @@ body: label: Steps to Reproduce description: List the smallest reliable reproduction steps. placeholder: | - 1. Open Bare Browser + 1. Open Lumen Browser 2. ... 3. ... validations: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 150e93e..e99a00e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Suggest an improvement for Bare Browser. +description: Suggest an improvement for Lumen Browser. title: "[Feature]: " labels: - enhancement @@ -15,7 +15,7 @@ body: id: proposal attributes: label: Proposal - description: What should Bare Browser do? + description: What should Lumen Browser do? validations: required: true - type: textarea diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9c6fbe5..a4081ae 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,6 +1,6 @@ # Code of Conduct -Bare Browser aims to be a practical, respectful open-source project. +Lumen Browser aims to be a practical, respectful open-source project. ## Expected Behavior diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f0f589..9f04c5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thanks for helping improve Bare Browser. +Thanks for helping improve Lumen Browser. ## Development Setup @@ -32,7 +32,7 @@ To launch the app as a foreground macOS bundle: - `Tests/MeridianBrowserTests`: unit tests. - `Docs`: architecture, threat model, release checklist, test plan, and WebKit limitations. -The public product name is Bare Browser. The Swift package and modules still use the historical `Meridian*` names. +The public product name is Lumen Browser. The Swift package and modules still use the historical `Meridian*` names. ## Pull Requests diff --git a/DebugFixtures/download-context-menu.html b/DebugFixtures/download-context-menu.html index cd14aa3..fcbe2a2 100644 --- a/DebugFixtures/download-context-menu.html +++ b/DebugFixtures/download-context-menu.html @@ -2,7 +2,7 @@ - Bare Browser Download Context Menu Fixture + Lumen Browser Download Context Menu Fixture