From 0e0fb94960b080b724b50a3f14145ecbb6a5dd63 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 27 Aug 2026 12:30:24 +1000 Subject: [PATCH 1/2] feat(swift-ios): add command drawer gesture foundation Define the command drawer geometry, gesture arbitration, responder ownership, and bounded focus renewal as a focused foundation slice. Restore the exact prior responder after an abandoned pull and cover cancellation, teardown, and eligibility paths with focused tests. --- .../Workspace/FeatureCommandDrawer.swift | 305 +++++ .../Workspace/FeatureCommandDrawerView.swift | 443 +++++++ .../FeatureCommandDrawerTests.swift | 1142 +++++++++++++++++ 3 files changed, 1890 insertions(+) create mode 100644 apps/swift-ios/Features/Workspace/FeatureCommandDrawer.swift create mode 100644 apps/swift-ios/Features/Workspace/FeatureCommandDrawerView.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/FeatureCommandDrawerTests.swift diff --git a/apps/swift-ios/Features/Workspace/FeatureCommandDrawer.swift b/apps/swift-ios/Features/Workspace/FeatureCommandDrawer.swift new file mode 100644 index 000000000000..2c4dc6daa7cd --- /dev/null +++ b/apps/swift-ios/Features/Workspace/FeatureCommandDrawer.swift @@ -0,0 +1,305 @@ +import CoreGraphics +import Foundation + +/// Geometry for the command palette's top drawer. +/// +/// The palette is a physical drawer hanging above the top edge of the +/// workspace: the finger pulls it down, the page below travels with it, and +/// releasing settles it to one of two rest positions. Every value here is a +/// pure function of the drag so the motion can be verified without UI +/// automation, and so the drawer never borrows a bottom-sheet presentation +/// whose animation would disagree with the direction of the finger. +enum FeatureCommandDrawerGeometry { + /// Floor so an unusually tall keyboard cannot squeeze the drawer shut. + static let minimumOpenHeight: CGFloat = 220 + /// Share of the drag that keeps travelling once the drawer is fully out. + static let overshootResistance: CGFloat = 0.22 + /// Travel away from the rest position the drawer started at that commits + /// the release to the other rest position. + /// + /// This is an absolute distance rather than a fraction of the open height + /// on purpose. The drawer opens to the full page, so a fraction made an + /// ordinary swipe — a hundred points or so — fall far short of committing, + /// and the palette could only be opened by dragging a third of the screen + /// or flicking hard. A swipe is a swipe regardless of how tall the drawer + /// it is pulling happens to be. + static let settleCommitDistance: CGFloat = 96 + /// How far ahead of the release the drawer's momentum is projected, so a + /// short fast swipe commits on the speed it was thrown at. + static let settleProjectionInterval: CGFloat = 0.14 + + /// Height a docked software keyboard covers inside the hosting window. + /// + /// Keyboard notifications report screen coordinates. Floating, split, and + /// undocked iPad keyboards must not shorten the full-width drawer, so the + /// frame only counts when it spans the window and reaches its bottom edge. + static func keyboardOverlap(keyboardFrame: CGRect, windowFrame: CGRect) -> CGFloat { + guard windowFrame.width > 0, + windowFrame.height > 0, + keyboardFrame.minX <= windowFrame.minX, + keyboardFrame.maxX >= windowFrame.maxX, + keyboardFrame.maxY >= windowFrame.maxY + else { return 0 } + + let overlap = keyboardFrame.intersection(windowFrame) + return overlap.isNull ? 0 : overlap.height + } + + /// Fully open covers the whole page: the drawer runs from the top of the + /// screen down to the keyboard's top edge, or to the home indicator when + /// there is no keyboard. No part of the page underneath stays visible. + /// + /// `availableHeight` must be the page height *before* keyboard avoidance + /// shrinks it, and `bottomInset` the home-indicator inset it already + /// excludes. The keyboard is measured from the bottom of the screen, so it + /// only intrudes into the page by whatever it covers beyond that inset — + /// subtracting its full height from an already-shrunken page counts the + /// keyboard twice and leaves a band of page showing beneath the drawer. + static func openHeight( + availableHeight: CGFloat, + keyboardHeight: CGFloat = 0, + bottomInset: CGFloat = 0 + ) -> CGFloat { + guard availableHeight > 0 else { return 0 } + let intrusion = max(0, keyboardHeight - max(0, bottomInset)) + let exposed = availableHeight - intrusion + return max(exposed, min(minimumOpenHeight, availableHeight)) + } + + /// Where the open drawer's bottom edge lands in window coordinates. The + /// drawer is only correct when this meets the top of whatever is below it. + static func openEdge( + windowHeight: CGFloat, + topInset: CGFloat, + bottomInset: CGFloat, + keyboardHeight: CGFloat = 0 + ) -> CGFloat { + let pageHeight = windowHeight - topInset - bottomInset + return topInset + openHeight( + availableHeight: pageHeight, + keyboardHeight: keyboardHeight, + bottomInset: bottomInset + ) + } + + /// Drawer edge position for a drag, measured down from the closed edge. + static func reveal( + baseReveal: CGFloat, + translation: CGFloat, + openHeight: CGFloat + ) -> CGFloat { + guard openHeight > 0 else { return 0 } + let raw = baseReveal + translation + guard raw > 0 else { return 0 } + guard raw > openHeight else { return raw } + return openHeight + (raw - openHeight) * overshootResistance + } + + /// Where the drawer layer sits for a given reveal, measured from the top of + /// the page it is laid out in. + /// + /// The drawer is presented *over* the workspace rather than pushing it: the + /// page underneath never translates, so this offset carries the entire + /// travel of the pull. At rest the whole drawer, including the window's top + /// inset it draws under, hangs above the top edge; at full reveal its + /// bottom edge lands at `topInset + openHeight` and its top sits exactly on + /// the window's top edge. + static func drawerOffset( + reveal: CGFloat, + openHeight: CGFloat, + topInset: CGFloat + ) -> CGFloat { + reveal - openHeight - topInset + } + + static func progress(reveal: CGFloat, openHeight: CGFloat) -> CGFloat { + guard openHeight > 0 else { return 0 } + return min(max(reveal / openHeight, 0), 1) + } + + /// Distance the drawer must travel away from where the drag started before + /// the release commits to the opposite rest position. Never more than half + /// the drawer, so a short drawer stays reachable in both directions. + static func commitDistance(openHeight: CGFloat) -> CGFloat { + min(settleCommitDistance, openHeight * 0.5) + } + + /// Where the drawer's edge is heading when the finger lifts. Position and + /// speed are the same quantity here, so a slow drag past the commit + /// distance and a fast flick short of it both settle the way they look. + static func projectedReveal(reveal: CGFloat, velocity: CGFloat) -> CGFloat { + reveal + velocity * settleProjectionInterval + } + + /// The settle is measured from the rest position the drag started at: an + /// opening pull commits once it has travelled the commit distance out, and + /// a closing push commits once it has travelled the same distance back. + /// Symmetry is what makes the drawer feel physical — otherwise the release + /// that opens it and the release that closes it answer to different rules. + static func settlesOpen( + reveal: CGFloat, + velocity: CGFloat, + openHeight: CGFloat, + wasOpen: Bool + ) -> Bool { + guard openHeight > 0 else { return false } + let projected = projectedReveal(reveal: reveal, velocity: velocity) + let commit = commitDistance(openHeight: openHeight) + return wasOpen + ? projected > openHeight - commit + : projected >= commit + } +} +/// Which touches may start the command gesture, and in which direction. +/// +/// Home's thread list and the thread transcript are native scroll views, so the +/// drawer must never be able to claim an ordinary drag from the middle of +/// either one. Eligibility is therefore a narrow band that tracks the drawer's +/// own leading edge: the top bar while the drawer is closed, and the drawer's +/// handle plus a little of the scrim beneath it while the drawer is open. +enum FeatureCommandDrawerGesture { + /// Band below the top inset that can start the pull, sized to the top bar. + static let topGrabHeight: CGFloat = 50 + /// Band above the open drawer's edge, covering its handle. + static let handleGrabHeight: CGFloat = 28 + /// Band below the open drawer's edge, covering the nearest scrim. + static let scrimGrabHeight: CGFloat = 64 + static let verticalToHorizontalRatio: CGFloat = 1.4 + static let minimumDirectionDistance: CGFloat = 8 + + /// Bands are expressed in window coordinates: the closed drawer's edge sits + /// at the top safe-area boundary, so `reveal` is measured from there. + static func grabBand(reveal: CGFloat, topInset: CGFloat) -> ClosedRange { + guard reveal > 0 else { + return topInset...(topInset + topGrabHeight) + } + let edge = topInset + reveal + return max(topInset, edge - handleGrabHeight)...(edge + scrimGrabHeight) + } + + static func canBeginTouch( + atY y: CGFloat, + reveal: CGFloat, + topInset: CGFloat + ) -> Bool { + grabBand(reveal: reveal, topInset: topInset).contains(y) + } + + /// Mirrors the detail surface's back-swipe policy: prefer real travel once + /// there is any, fall back to velocity at gesture-begin time, and require + /// the motion to be clearly vertical before claiming it. + static func shouldBegin( + velocity: CGPoint, + translation: CGPoint, + isOpen: Bool + ) -> Bool { + let direction = hypot(translation.x, translation.y) >= minimumDirectionDistance + ? translation + : velocity + guard abs(direction.y) >= abs(direction.x) * verticalToHorizontalRatio else { + return false + } + return isOpen ? direction.y != 0 : direction.y > 0 + } +} + +/// Presentation state of the drawer. Drag updates are absolute against the +/// reveal captured when the drag began, so a drag that starts on a partly +/// settled drawer stays attached to the finger instead of jumping. +struct FeatureCommandDrawerState: Equatable, Sendable { + private(set) var reveal: CGFloat = 0 + private(set) var isOpen = false + private(set) var isDragging = false + private var dragBaseline: CGFloat = 0 + + var isVisible: Bool { isOpen || isDragging || reveal > 0 } + + mutating func beginDrag() { + isDragging = true + dragBaseline = reveal + } + + mutating func updateDrag(translation: CGFloat, openHeight: CGFloat) { + guard isDragging else { return } + reveal = FeatureCommandDrawerGeometry.reveal( + baseReveal: dragBaseline, + translation: translation, + openHeight: openHeight + ) + } + + @discardableResult + mutating func endDrag(velocity: CGFloat, openHeight: CGFloat) -> Bool { + guard isDragging else { return isOpen } + let opens = FeatureCommandDrawerGeometry.settlesOpen( + reveal: reveal, + velocity: velocity, + openHeight: openHeight, + wasOpen: isOpen + ) + settle(open: opens, openHeight: openHeight) + return opens + } + + /// A cancelled pan returns to the rest position the drawer came from. + mutating func cancelDrag(openHeight: CGFloat) { + guard isDragging else { return } + settle(open: isOpen, openHeight: openHeight) + } + + /// Closing needs no geometry: the closed rest position is always the edge. + mutating func close() { + isDragging = false + isOpen = false + reveal = 0 + dragBaseline = 0 + } + + mutating func settle(open: Bool, openHeight: CGFloat) { + isDragging = false + isOpen = open + reveal = open ? openHeight : 0 + dragBaseline = reveal + } + + /// Keeps a settled drawer pinned to its edge when the viewport resizes. + mutating func synchronize(openHeight: CGFloat) { + guard !isDragging else { return } + reveal = isOpen ? openHeight : 0 + dragBaseline = reveal + } +} + +/// When the palette's search field owns the keyboard. +/// +/// The drawer is a typing surface, so focus follows presentation rather than a +/// separate tap: the keyboard comes up as soon as the pull starts, which also +/// means the drawer's fully-open height is already keyboard-constrained by the +/// time the drag is released and the settle lands in one motion. +enum FeatureCommandDrawerFocus { + static func searchIsFocused(for state: FeatureCommandDrawerState) -> Bool { + state.isVisible + } + + /// Whether the focus request has to be made again. + /// + /// Asking at the start of the pull is what puts the keyboard's height into + /// the open height before the finger lifts, but at that moment the search + /// field is still above the window's top edge, and a request for a field + /// that is not on screen yet can simply be dropped. A long drag hid that: + /// the field was on screen for most of a second before the release, so a + /// later pass took the focus anyway. An ordinary swipe settles in a quarter + /// of a second, so the drawer can arrive at its rest position with nothing + /// focused and no further state change to trigger a retry. + /// + /// The caller records one renewal after the drawer settles. Bounding the + /// retry prevents an unavailable field or inactive window from producing a + /// focus request on every render pass. + static func needsFocusRenewal( + state: FeatureCommandDrawerState, + isFocused: Bool, + hasRenewedAfterSettle: Bool + ) -> Bool { + state.isOpen && !isFocused && !hasRenewedAfterSettle + } +} diff --git a/apps/swift-ios/Features/Workspace/FeatureCommandDrawerView.swift b/apps/swift-ios/Features/Workspace/FeatureCommandDrawerView.swift new file mode 100644 index 000000000000..7181336b1ece --- /dev/null +++ b/apps/swift-ios/Features/Workspace/FeatureCommandDrawerView.swift @@ -0,0 +1,443 @@ +import SwiftUI +import UIKit + +/// A responder the drawer may return focus to after it gives up ownership. +/// +/// The protocol keeps the ownership decision testable without a live keyboard. +/// UIKit responders use the conformance below in the running app. +@MainActor +protocol FeatureCommandDrawerRestorableResponder: AnyObject { + var canRestoreCommandDrawerFocus: Bool { get } + @discardableResult func restoreCommandDrawerFocus() -> Bool +} + +extension UIResponder: FeatureCommandDrawerRestorableResponder { + var canRestoreCommandDrawerFocus: Bool { + guard canBecomeFirstResponder || isFirstResponder else { return false } + guard let view = commandDrawerRestorationView else { return false } + guard let window = view.window, + !window.isHidden, + window.alpha > 0.01, + window.isUserInteractionEnabled else { return false } + + var ancestor: UIView? = view + while let candidate = ancestor { + guard !candidate.isHidden, + candidate.alpha > 0.01, + candidate.isUserInteractionEnabled else { return false } + if let control = candidate as? UIControl, !control.isEnabled { + return false + } + ancestor = candidate.superview + } + return true + } + + private var commandDrawerRestorationView: UIView? { + if let view = self as? UIView { return view } + if let viewController = self as? UIViewController { + return viewController.viewIfLoaded + } + + var candidate = next + while let responder = candidate { + if let view = responder as? UIView { return view } + if let viewController = responder as? UIViewController { + return viewController.viewIfLoaded + } + candidate = responder.next + } + return nil + } + + @discardableResult + func restoreCommandDrawerFocus() -> Bool { + becomeFirstResponder() + } +} + +/// Owns the responder handoff for one drawer presentation lifecycle. +/// +/// The prior responder is captured once, before drawer search takes focus. A +/// short abandoned pull restores it. A completed open keeps the ownership +/// token until the drawer closes, then restores the same responder only if the +/// view still belongs to a visible window. No delay or global keyboard reset is +/// involved. +@MainActor +final class FeatureCommandDrawerResponderOwnership { + private weak var priorResponder: (any FeatureCommandDrawerRestorableResponder)? + private(set) var ownsFocusTransfer = false + + func begin(from responder: (any FeatureCommandDrawerRestorableResponder)?) { + guard !ownsFocusTransfer else { return } + priorResponder = responder + ownsFocusTransfer = true + } + + /// Keeps ownership while open. Settling closed ends the lifecycle and + /// restores the responder that owned focus before the pull. + @discardableResult + func settle(open: Bool) -> Bool { + guard !open else { return false } + return finish(restoringPrior: true) + } + + /// A recognizer cancellation returns to the rest state it began from. A + /// cancelled opening pull restores the prior responder; cancelling a close + /// keeps the drawer's existing ownership token. + @discardableResult + func cancel(returningToOpen open: Bool) -> Bool { + settle(open: open) + } + + /// Selection can close the drawer while navigating elsewhere. That path + /// explicitly declines restoration so an old composer cannot steal focus + /// from the destination. + @discardableResult + func close(restoringPrior: Bool = true) -> Bool { + finish(restoringPrior: restoringPrior) + } + + @discardableResult + private func finish(restoringPrior: Bool) -> Bool { + guard ownsFocusTransfer else { return false } + let responder = priorResponder + priorResponder = nil + ownsFocusTransfer = false + guard restoringPrior, + let responder, + responder.canRestoreCommandDrawerFocus else { + return false + } + return responder.restoreCommandDrawerFocus() + } +} + +@MainActor +private final class FeatureCommandDrawerResponderProbe: NSObject { + weak var responder: UIResponder? +} + +private extension UIResponder { + @objc func captureCommandDrawerFirstResponder( + _ probe: FeatureCommandDrawerResponderProbe + ) { + probe.responder = self + } +} + +enum FeatureCommandDrawerResponderLookup { + /// Finds the exact UIKit responder before drawer search asks for focus. + /// The responder chain answers this directly, avoiding a synchronous walk + /// over a long transcript's full UIKit view tree. + @MainActor + static func firstResponder(in window: UIWindow?) -> UIResponder? { + guard let window else { return nil } + let probe = FeatureCommandDrawerResponderProbe() + UIApplication.shared.sendAction( + #selector(UIResponder.captureCommandDrawerFirstResponder(_:)), + to: nil, + from: probe, + for: nil + ) + guard let responder = probe.responder, + owningWindow(of: responder) === window else { return nil } + return responder + } + + @MainActor + private static func owningWindow(of responder: UIResponder) -> UIWindow? { + if let window = responder as? UIWindow { return window } + if let view = responder as? UIView { return view.window } + if let viewController = responder as? UIViewController { + return viewController.viewIfLoaded?.window + } + + var next = responder.next + while let candidate = next { + if let window = candidate as? UIWindow { return window } + if let view = candidate as? UIView, let window = view.window { return window } + next = candidate.next + } + return nil + } +} + +/// The command gesture uses a native pan recognizer for the same reason the +/// detail surface's back swipe does: a SwiftUI `DragGesture` can begin before +/// it knows the axis of the motion and would compete with Home's recycled +/// collection view and the thread transcript. This recognizer refuses every +/// touch that does not start in the drawer's grab band, so ordinary list +/// scrolling is never a candidate for the palette. +struct FeatureCommandDrawerGestureView: UIViewRepresentable { + let reveal: CGFloat + let isOpen: Bool + let onBegan: (UIResponder?) -> Void + let onChanged: (CGFloat) -> Void + let onEnded: (CGFloat) -> Void + let onCancelled: () -> Void + + static func cancelsActiveDragWhenUninstalled( + state: UIGestureRecognizer.State + ) -> Bool { + state == .began || state == .changed + } + + static func takesPriority(over recognizer: UIGestureRecognizer) -> Bool { + guard let scrollView = recognizer.view as? UIScrollView else { return false } + return recognizer === scrollView.panGestureRecognizer + } + + @MainActor + static func afterCurrentViewUpdate(_ action: @escaping @MainActor () -> Void) { + Task { @MainActor in action() } + } + + /// Text entry owns its own drags for caret and selection handles. + @MainActor + static func isTextEntry(_ view: UIView?) -> Bool { + var current = view + while let candidate = current { + if candidate is UITextField { return true } + if let textView = candidate as? UITextView, + textView.isEditable || textView.isFirstResponder { + return true + } + current = candidate.superview + } + return false + } + + @MainActor + static func canReceiveTouch( + view: UIView?, + location: CGPoint, + window: UIWindow?, + gestureHost: UIView?, + reveal: CGFloat, + topInset: CGFloat, + hasPresentedViewController: Bool + ) -> Bool { + guard let window, + let gestureHost, + window === gestureHost.window, + !hasPresentedViewController, + window.bounds.contains(location), + FeatureCommandDrawerGesture.canBeginTouch( + atY: location.y, + reveal: reveal, + topInset: topInset + ) else { + return false + } + return !isTextEntry(view) + } + + func makeUIView(context: Context) -> InstallerView { + let view = InstallerView() + apply(to: view) + return view + } + + func updateUIView(_ view: InstallerView, context: Context) { + apply(to: view) + } + + static func dismantleUIView(_ view: InstallerView, coordinator: ()) { + view.uninstallGesture() + } + + private func apply(to view: InstallerView) { + view.update( + reveal: reveal, + isOpen: isOpen, + onBegan: onBegan, + onChanged: onChanged, + onEnded: onEnded, + onCancelled: onCancelled + ) + } + + final class InstallerView: UIView { + private var reveal: CGFloat = 0 + private var isOpen = false + private var onBegan: ((UIResponder?) -> Void)? + private var onChanged: ((CGFloat) -> Void)? + private var onEnded: ((CGFloat) -> Void)? + private var onCancelled: (() -> Void)? + private weak var gestureHost: UIView? + private var panGesture: UIPanGestureRecognizer? + private var gestureDelegate: GestureDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + uninstallGesture() + } else { + installGestureIfPossible() + } + } + + func update( + reveal: CGFloat, + isOpen: Bool, + onBegan: @escaping (UIResponder?) -> Void, + onChanged: @escaping (CGFloat) -> Void, + onEnded: @escaping (CGFloat) -> Void, + onCancelled: @escaping () -> Void + ) { + self.reveal = reveal + self.isOpen = isOpen + self.onBegan = onBegan + self.onChanged = onChanged + self.onEnded = onEnded + self.onCancelled = onCancelled + installGestureIfPossible() + } + + func uninstallGesture() { + let removedGesture = panGesture + let removedHost = gestureHost + let cancellation = onCancelled + let shouldCancel = removedGesture.map { + FeatureCommandDrawerGestureView.cancelsActiveDragWhenUninstalled( + state: $0.state + ) + } ?? false + + if let removedGesture, let removedHost { + removedHost.removeGestureRecognizer(removedGesture) + } + panGesture = nil + gestureDelegate = nil + gestureHost = nil + + if shouldCancel { + // UIKit teardown can happen during `updateUIView`. Cross that + // transaction boundary before mutating the owning SwiftUI state. + FeatureCommandDrawerGestureView.afterCurrentViewUpdate { + cancellation?() + } + } + } + + // SwiftUI hosts this representable beside, rather than above, the + // workspace, so install on their shared root view. Touch eligibility is + // scoped in window coordinates by the drawer's current grab band. + private func installGestureIfPossible() { + guard let window, let host = window.rootViewController?.view else { return } + guard gestureHost !== host else { return } + + uninstallGesture() + let panGesture = UIPanGestureRecognizer( + target: self, + action: #selector(handlePan(_:)) + ) + let gestureDelegate = GestureDelegate(owner: self) + panGesture.delegate = gestureDelegate + panGesture.cancelsTouchesInView = false + panGesture.delaysTouchesBegan = false + panGesture.maximumNumberOfTouches = 1 + host.addGestureRecognizer(panGesture) + gestureHost = host + self.panGesture = panGesture + self.gestureDelegate = gestureDelegate + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + switch gesture.state { + case .began: + onBegan?(FeatureCommandDrawerResponderLookup.firstResponder(in: window)) + onChanged?(gesture.translation(in: gesture.view).y) + case .changed: + onChanged?(gesture.translation(in: gesture.view).y) + case .ended: + onEnded?(gesture.velocity(in: gesture.view).y) + case .cancelled, .failed: + onCancelled?() + default: + break + } + } + + // Window coordinates keep the grab band unambiguous: the closed drawer's + // edge is the window's own top safe-area boundary, which is also where + // the app's top bar starts, so the band needs no SwiftUI measurement. + fileprivate func canReceive(_ touch: UITouch) -> Bool { + let location = touch.location(in: nil) + return FeatureCommandDrawerGestureView.canReceiveTouch( + view: touch.view, + location: location, + window: window, + gestureHost: gestureHost, + reveal: reveal, + topInset: window?.safeAreaInsets.top ?? 0, + hasPresentedViewController: + window?.rootViewController?.presentedViewController != nil + ) + } + + fileprivate func canBegin(with gesture: UIPanGestureRecognizer) -> Bool { + FeatureCommandDrawerGesture.shouldBegin( + velocity: gesture.velocity(in: gesture.view), + translation: gesture.translation(in: gesture.view), + isOpen: isOpen + ) + } + + private final class GestureDelegate: NSObject, UIGestureRecognizerDelegate { + weak var owner: InstallerView? + + init(owner: InstallerView) { + self.owner = owner + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let owner, + let panGesture = gestureRecognizer as? UIPanGestureRecognizer else { + return false + } + return owner.canBegin(with: panGesture) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + owner?.canReceive(touch) ?? false + } + + // The command gesture deliberately does not run alongside scroll + // views. It can only begin in the grab band, and inside that band it + // owns the drag outright rather than nudging a list at the same time. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + otherGestureRecognizer is UIScreenEdgePanGestureRecognizer + } + + /// Inside the narrow grab band the drawer wins over a scroll view's + /// pan recognizer. Without an explicit ordering, Home's collection + /// view can cross its threshold first and prevent the drawer from + /// ever receiving `.began`. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + FeatureCommandDrawerGestureView.takesPriority( + over: otherGestureRecognizer + ) + } + } + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureCommandDrawerTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureCommandDrawerTests.swift new file mode 100644 index 000000000000..aa461da053da --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureCommandDrawerTests.swift @@ -0,0 +1,1142 @@ +import CoreGraphics +import Foundation +import Testing +import UIKit +@testable import T3Code + +@Suite("Command palette top drawer") +struct FeatureCommandDrawerTests { + // MARK: - Geometry + + // iPhone-shaped window used for the coverage invariants below. + private static let windowHeight: CGFloat = 874 + private static let topInset: CGFloat = 62 + private static let bottomInset: CGFloat = 34 + private static let keyboardHeight: CGFloat = 336 + + @Test + func floatingAndUndockedKeyboardsDoNotShortenTheDrawer() { + let windowFrame = CGRect(x: 0, y: 0, width: 393, height: Self.windowHeight) + + #expect( + FeatureCommandDrawerGeometry.keyboardOverlap( + keyboardFrame: CGRect(x: 70, y: 654, width: 300, height: 220), + windowFrame: windowFrame + ) == 0 + ) + #expect( + FeatureCommandDrawerGeometry.keyboardOverlap( + keyboardFrame: CGRect(x: 0, y: 500, width: 393, height: 300), + windowFrame: windowFrame + ) == 0 + ) + } + + @Test + func dockedKeyboardUsesTheHostingWindowsFrame() { + let windowFrame = CGRect(x: 100, y: 80, width: 393, height: 700) + + #expect( + FeatureCommandDrawerGeometry.keyboardOverlap( + keyboardFrame: CGRect(x: 0, y: 500, width: 700, height: 400), + windowFrame: windowFrame + ) == 280 + ) + #expect( + FeatureCommandDrawerGeometry.keyboardOverlap( + keyboardFrame: CGRect(x: 0, y: 900, width: 700, height: 300), + windowFrame: windowFrame + ) == 0 + ) + } + + @Test + func theOpenDrawersBottomEdgeMeetsTheTopOfTheKeyboard() { + // The rejected build left a band of the page showing between the + // drawer and the keyboard. The drawer's edge must land exactly on the + // keyboard's top edge so nothing underneath is visible. + let edge = FeatureCommandDrawerGeometry.openEdge( + windowHeight: Self.windowHeight, + topInset: Self.topInset, + bottomInset: Self.bottomInset, + keyboardHeight: Self.keyboardHeight + ) + let keyboardTop = Self.windowHeight - Self.keyboardHeight + + #expect(edge == keyboardTop) + #expect(edge == 538) + } + + @Test + func withoutAKeyboardTheDrawerReachesTheHomeIndicator() { + let edge = FeatureCommandDrawerGeometry.openEdge( + windowHeight: Self.windowHeight, + topInset: Self.topInset, + bottomInset: Self.bottomInset + ) + // Everything above the home indicator is covered. + #expect(edge == Self.windowHeight - Self.bottomInset) + #expect(edge == 840) + } + + @Test + func theKeyboardIsNeverSubtractedTwice() { + // The page height already excludes the home indicator, and the keyboard + // is measured from the bottom of the screen, so only the part of the + // keyboard beyond that inset actually covers page content. + let pageHeight = Self.windowHeight - Self.topInset - Self.bottomInset + let open = FeatureCommandDrawerGeometry.openHeight( + availableHeight: pageHeight, + keyboardHeight: Self.keyboardHeight, + bottomInset: Self.bottomInset + ) + #expect(open == pageHeight - (Self.keyboardHeight - Self.bottomInset)) + #expect(open == 476) + + // Ignoring the inset would shorten the drawer by the inset and expose + // that much of the page; guard against regressing to it. + #expect(open != pageHeight - Self.keyboardHeight) + } + + @Test + func withoutAKeyboardTheDrawerCoversTheWholePage() { + #expect(FeatureCommandDrawerGeometry.openHeight(availableHeight: 778) == 778) + #expect( + FeatureCommandDrawerGeometry.openHeight( + availableHeight: 778, + keyboardHeight: 0, + bottomInset: 34 + ) == 778 + ) + // A keyboard that only covers the home indicator takes nothing from it. + #expect( + FeatureCommandDrawerGeometry.openHeight( + availableHeight: 778, + keyboardHeight: 30, + bottomInset: 34 + ) == 778 + ) + } + + @Test + func anOversizedKeyboardCannotSqueezeTheDrawerShut() { + // Floor keeps the palette usable when the keyboard is unusually tall. + #expect( + FeatureCommandDrawerGeometry.openHeight( + availableHeight: 500, + keyboardHeight: 480, + bottomInset: 0 + ) == FeatureCommandDrawerGeometry.minimumOpenHeight + ) + // The floor never exceeds the space that actually exists. + #expect( + FeatureCommandDrawerGeometry.openHeight( + availableHeight: 180, + keyboardHeight: 170 + ) == 180 + ) + #expect(FeatureCommandDrawerGeometry.openHeight(availableHeight: 0) == 0) + // A negative or absent report is treated as no keyboard at all. + #expect( + FeatureCommandDrawerGeometry.openHeight( + availableHeight: 778, + keyboardHeight: -40 + ) == 778 + ) + } + + // MARK: - Presented over the page (issue #122 rework) + + @Test + func theDrawerCarriesTheWholeTravelSoThePageUnderneathNeverMoves() { + // Alex rejected the build where the page translated with the drawer: + // the whole screen looked shoved downwards. The drawer is presented + // over the workspace, so every point of the pull has to land in the + // drawer's own offset and nothing else. + let openHeight: CGFloat = 778 + let topInset: CGFloat = 62 + let atRest = FeatureCommandDrawerGeometry.drawerOffset( + reveal: 0, openHeight: openHeight, topInset: topInset + ) + + for reveal in stride(from: CGFloat(0), through: openHeight, by: 64) { + let offset = FeatureCommandDrawerGeometry.drawerOffset( + reveal: reveal, openHeight: openHeight, topInset: topInset + ) + // All of the movement, and only the movement, is the drawer's. + #expect(offset - atRest == reveal) + } + } + + @Test + func theClosedDrawerHangsEntirelyAboveTheTopEdge() { + let openHeight: CGFloat = 778 + let topInset: CGFloat = 62 + // The layer is `topInset + openHeight` tall and top-aligned in the page, + // so its bottom edge is the offset plus its own height. + let height = topInset + openHeight + let closedBottom = FeatureCommandDrawerGeometry.drawerOffset( + reveal: 0, openHeight: openHeight, topInset: topInset + ) + height + + #expect(closedBottom == 0) + } + + @Test + func theOpenDrawersBottomEdgeAgreesWithTheOpenEdgeItAdvertises() { + let windowHeight: CGFloat = 874 + let topInset: CGFloat = 62 + let bottomInset: CGFloat = 34 + let pageHeight = windowHeight - topInset - bottomInset + let openHeight = FeatureCommandDrawerGeometry.openHeight( + availableHeight: pageHeight, + keyboardHeight: Self.keyboardHeight, + bottomInset: bottomInset + ) + let height = topInset + openHeight + // Page-local bottom edge of the fully revealed drawer… + let bottom = FeatureCommandDrawerGeometry.drawerOffset( + reveal: openHeight, openHeight: openHeight, topInset: topInset + ) + height + // …converted to window coordinates must be the advertised open edge, so + // removing the page's translation cannot have moved the drawer. + #expect(bottom + topInset == FeatureCommandDrawerGeometry.openEdge( + windowHeight: windowHeight, + topInset: topInset, + bottomInset: bottomInset, + keyboardHeight: Self.keyboardHeight + )) + } + + // MARK: - Autofocus + + @Test + func aSwipeThatSettlesBeforeTheFieldIsOnScreenStillGetsTheKeyboard() { + // Test 91: the keyboard stopped coming up on its own. The request is + // made at the start of the pull, when the search field is still above + // the window's top edge and the request can be dropped; the long drag + // of the rejected build hid that, and a quarter-second swipe does not. + var state = FeatureCommandDrawerState() + state.beginDrag() + state.updateDrag(translation: 140, openHeight: 442) + + // Nothing to renew mid-pull: the initial request owns that window, and + // renewing here would fight it. + #expect( + FeatureCommandDrawerFocus.needsFocusRenewal( + state: state, + isFocused: false, + hasRenewedAfterSettle: false + ) == false + ) + + state.endDrag(velocity: 0, openHeight: 442) + #expect(state.isOpen) + // The drawer has arrived and nothing took focus, so ask again. + #expect(FeatureCommandDrawerFocus.needsFocusRenewal( + state: state, + isFocused: false, + hasRenewedAfterSettle: false + )) + // …and stop asking the moment it lands, so the renewal cannot loop. + #expect( + FeatureCommandDrawerFocus.needsFocusRenewal( + state: state, + isFocused: true, + hasRenewedAfterSettle: false + ) == false + ) + } + + @Test + func aClosedDrawerNeverAsksForTheKeyboard() { + var state = FeatureCommandDrawerState() + #expect( + FeatureCommandDrawerFocus.needsFocusRenewal( + state: state, + isFocused: false, + hasRenewedAfterSettle: false + ) == false + ) + + // An abandoned pull settles closed and must take the keyboard with it + // rather than renewing a request behind a drawer nobody can see. + state.beginDrag() + state.updateDrag(translation: 40, openHeight: 442) + state.endDrag(velocity: 0, openHeight: 442) + #expect(state.isOpen == false) + #expect( + FeatureCommandDrawerFocus.needsFocusRenewal( + state: state, + isFocused: false, + hasRenewedAfterSettle: false + ) == false + ) + + state.settle(open: true, openHeight: 442) + state.close() + #expect( + FeatureCommandDrawerFocus.needsFocusRenewal( + state: state, + isFocused: false, + hasRenewedAfterSettle: false + ) == false + ) + } + + @Test + func focusRenewalIsBoundedWhenTheFieldCannotAcceptFocus() { + var state = FeatureCommandDrawerState() + state.settle(open: true, openHeight: 442) + + #expect(FeatureCommandDrawerFocus.needsFocusRenewal( + state: state, + isFocused: false, + hasRenewedAfterSettle: false + )) + #expect(FeatureCommandDrawerFocus.needsFocusRenewal( + state: state, + isFocused: false, + hasRenewedAfterSettle: true + ) == false) + } + + @Test + func anyPathThatOpensTheDrawerAsksForTheKeyboard() { + // Not only the swipe: a drawer opened without a drag at all must still + // arrive focused. + var state = FeatureCommandDrawerState() + state.settle(open: true, openHeight: 442) + + #expect(FeatureCommandDrawerFocus.searchIsFocused(for: state)) + #expect(FeatureCommandDrawerFocus.needsFocusRenewal( + state: state, + isFocused: false, + hasRenewedAfterSettle: false + )) + } + + @Test + func presentingTheDrawerGivesTheSearchFieldTheKeyboard() { + var state = FeatureCommandDrawerState() + #expect(FeatureCommandDrawerFocus.searchIsFocused(for: state) == false) + + // The pull itself focuses, so the keyboard is already up — and already + // accounted for in the open height — by the time the drag is released. + state.beginDrag() + state.updateDrag(translation: 30, openHeight: 442) + #expect(FeatureCommandDrawerFocus.searchIsFocused(for: state)) + + state.settle(open: true, openHeight: 442) + #expect(FeatureCommandDrawerFocus.searchIsFocused(for: state)) + + state.close() + #expect(FeatureCommandDrawerFocus.searchIsFocused(for: state) == false) + } + + @Test + func anAbandonedPullTakesTheKeyboardBackDownWithIt() { + var state = FeatureCommandDrawerState() + state.beginDrag() + state.updateDrag(translation: 40, openHeight: 442) + #expect(FeatureCommandDrawerFocus.searchIsFocused(for: state)) + + let opened = state.endDrag(velocity: 0, openHeight: 442) + #expect(opened == false) + #expect(FeatureCommandDrawerFocus.searchIsFocused(for: state) == false) + } + + @Test @MainActor + func anAbandonedPullRestoresTheExactPriorResponder() { + let composer = TestResponder() + let ownership = FeatureCommandDrawerResponderOwnership() + + ownership.begin(from: composer) + #expect(ownership.ownsFocusTransfer) + #expect(ownership.settle(open: false)) + #expect(composer.restoreCount == 1) + #expect(ownership.ownsFocusTransfer == false) + } + + @Test @MainActor + func aCompletedOpenKeepsOwnershipUntilTheDrawerCloses() { + let composer = TestResponder() + let drawerSearch = TestResponder() + let ownership = FeatureCommandDrawerResponderOwnership() + + ownership.begin(from: composer) + #expect(ownership.settle(open: true) == false) + #expect(ownership.ownsFocusTransfer) + + // A close gesture begins while drawer search is first responder. It + // must not replace the responder captured before the opening pull. + ownership.begin(from: drawerSearch) + #expect(ownership.close()) + #expect(composer.restoreCount == 1) + #expect(drawerSearch.restoreCount == 0) + } + + @Test @MainActor + func closingDoesNotRestoreAResponderThatLeftTheWindow() { + let composer = TestResponder(canRestore: false) + let ownership = FeatureCommandDrawerResponderOwnership() + + ownership.begin(from: composer) + #expect(ownership.settle(open: true) == false) + #expect(ownership.close() == false) + #expect(composer.restoreCount == 0) + #expect(ownership.ownsFocusTransfer == false) + } + + @Test @MainActor + func selectionCanCloseWithoutRestoringTheOldDestination() { + let composer = TestResponder() + let ownership = FeatureCommandDrawerResponderOwnership() + + ownership.begin(from: composer) + #expect(ownership.settle(open: true) == false) + #expect(ownership.close(restoringPrior: false) == false) + #expect(composer.restoreCount == 0) + } + + @Test @MainActor + func aPullWithNoPriorResponderDoesNotFabricateFocus() { + let ownership = FeatureCommandDrawerResponderOwnership() + + ownership.begin(from: nil) + #expect(ownership.settle(open: false) == false) + #expect(ownership.ownsFocusTransfer == false) + } + + @Test @MainActor + func cancellingAnOpeningPullRestoresButCancellingAClosingPushRetainsOwnership() { + let composer = TestResponder() + let ownership = FeatureCommandDrawerResponderOwnership() + + ownership.begin(from: composer) + #expect(ownership.cancel(returningToOpen: false)) + #expect(composer.restoreCount == 1) + + ownership.begin(from: composer) + #expect(ownership.settle(open: true) == false) + #expect(ownership.cancel(returningToOpen: true) == false) + #expect(ownership.ownsFocusTransfer) + #expect(ownership.close()) + #expect(composer.restoreCount == 2) + } + + @Test @MainActor + func aDeallocatedPriorResponderIsNotRetainedOrRestored() { + let ownership = FeatureCommandDrawerResponderOwnership() + var composer: TestResponder? = TestResponder() + + ownership.begin(from: composer) + composer = nil + + #expect(ownership.close() == false) + #expect(ownership.ownsFocusTransfer == false) + } + + @Test @MainActor + func UIKitEligibilityRejectsHiddenDisabledAndDetachedResponderPaths() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + let root = UIViewController() + let container = UIView(frame: window.bounds) + let field = UITextField(frame: CGRect(x: 20, y: 80, width: 200, height: 44)) + root.view = container + container.addSubview(field) + window.rootViewController = root + window.isHidden = false + defer { window.isHidden = true } + + #expect(field.canRestoreCommandDrawerFocus) + + container.isHidden = true + #expect(field.canRestoreCommandDrawerFocus == false) + container.isHidden = false + + container.isUserInteractionEnabled = false + #expect(field.canRestoreCommandDrawerFocus == false) + container.isUserInteractionEnabled = true + + window.isHidden = true + #expect(field.canRestoreCommandDrawerFocus == false) + window.isHidden = false + + field.removeFromSuperview() + #expect(field.canRestoreCommandDrawerFocus == false) + } + + @Test @MainActor + func UIKitEligibilityAlsoChecksAControllerRespondersView() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + let controller = TestViewController() + window.rootViewController = controller + window.isHidden = false + defer { window.isHidden = true } + + #expect(controller.canRestoreCommandDrawerFocus) + + controller.view.isHidden = true + #expect(controller.canRestoreCommandDrawerFocus == false) + controller.view.isHidden = false + + window.rootViewController = nil + #expect(controller.canRestoreCommandDrawerFocus == false) + } + + @Test @MainActor + func responderLookupFindsOnlyTheRequestedWindowsNestedFirstResponder() { + guard let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap(\.windows) + .first(where: \.isKeyWindow) else { + Issue.record("The feature test host has no key window") + return + } + let priorResponder = FeatureCommandDrawerResponderLookup.firstResponder(in: window) + let container = UIView(frame: window.bounds) + let field = UITextField(frame: CGRect(x: 20, y: 80, width: 200, height: 44)) + container.addSubview(field) + window.addSubview(container) + defer { + field.resignFirstResponder() + container.removeFromSuperview() + _ = priorResponder?.becomeFirstResponder() + } + + #expect(field.becomeFirstResponder()) + #expect(FeatureCommandDrawerResponderLookup.firstResponder(in: window) === field) + + let otherWindow = UIWindow(frame: window.bounds) + otherWindow.rootViewController = UIViewController() + #expect(FeatureCommandDrawerResponderLookup.firstResponder(in: otherWindow) == nil) + + field.resignFirstResponder() + #expect(FeatureCommandDrawerResponderLookup.firstResponder(in: window) == nil) + } + + @Test + func revealTracksTheFingerOneToOneWhileTheDrawerIsComingOut() { + for travel in stride(from: CGFloat(0), through: 400, by: 50) { + #expect( + FeatureCommandDrawerGeometry.reveal( + baseReveal: 0, + translation: travel, + openHeight: 420 + ) == travel + ) + } + } + + @Test + func revealClampsAtTheClosedEdgeAndResistsPastFullyOpen() { + #expect( + FeatureCommandDrawerGeometry.reveal( + baseReveal: 0, + translation: -120, + openHeight: 420 + ) == 0 + ) + + let overshoot = FeatureCommandDrawerGeometry.reveal( + baseReveal: 0, + translation: 520, + openHeight: 420 + ) + #expect(overshoot > 420) + #expect(overshoot < 520) + #expect(abs(overshoot - (420 + 100 * 0.22)) < 0.0001) + } + + @Test + func revealIsAbsoluteAgainstTheDragBaselineSoAnOpenDrawerDoesNotJump() { + // A drag that starts on the open drawer begins where the finger is. + #expect( + FeatureCommandDrawerGeometry.reveal( + baseReveal: 420, + translation: 0, + openHeight: 420 + ) == 420 + ) + #expect( + FeatureCommandDrawerGeometry.reveal( + baseReveal: 420, + translation: -150, + openHeight: 420 + ) == 270 + ) + } + + @Test + func progressIsTheClampedFractionOfTheOpenHeight() { + #expect(FeatureCommandDrawerGeometry.progress(reveal: 0, openHeight: 400) == 0) + #expect(FeatureCommandDrawerGeometry.progress(reveal: 200, openHeight: 400) == 0.5) + #expect(FeatureCommandDrawerGeometry.progress(reveal: 460, openHeight: 400) == 1) + #expect(FeatureCommandDrawerGeometry.progress(reveal: 100, openHeight: 0) == 0) + } + + // MARK: - Settle thresholds + + @Test + func releasingBeforeTheCommitDistanceSettlesClosedAndAfterItSettlesOpen() { + let openHeight: CGFloat = 400 + let commit = FeatureCommandDrawerGeometry.commitDistance(openHeight: openHeight) + #expect(commit == FeatureCommandDrawerGeometry.settleCommitDistance) + + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: commit - 1, + velocity: 0, + openHeight: openHeight, + wasOpen: false + ) == false + ) + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: commit, + velocity: 0, + openHeight: openHeight, + wasOpen: false + ) + ) + } + + @Test + func anOrdinarySwipeDownTheTopBarOpensTheFullPageDrawer() { + // Issue #122. The drawer opens to the whole page, so a settle measured + // as a fraction of the open height demanded a third of the screen of + // travel: an ordinary swipe fell short and snapped shut, and the + // palette could effectively only be opened by pressing and dragging. + let openHeight = FeatureCommandDrawerGeometry.openHeight(availableHeight: 778) + let swipe: CGFloat = 140 + + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: swipe, + velocity: 0, + openHeight: openHeight, + wasOpen: false + ) + ) + // The rejected rule: 40% of a full-page drawer is most of the reachable + // screen, and this swipe is nowhere near it. + #expect(swipe < openHeight * 0.4) + + // A brisk swipe that has barely started travelling still commits on the + // speed it was thrown at. + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: 30, + velocity: 900, + openHeight: openHeight, + wasOpen: false + ) + ) + } + + @Test + func aCommittedPullIsMeasuredFromTheRestPositionTheDragStartedAt() { + let openHeight: CGFloat = 400 + let commit = FeatureCommandDrawerGeometry.commitDistance(openHeight: openHeight) + + // Closing takes the same short push back that opening took out, rather + // than having to drag the drawer most of the way up again. + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: openHeight - commit, + velocity: 0, + openHeight: openHeight, + wasOpen: true + ) == false + ) + // Barely moving an open drawer leaves it open. + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: openHeight - 20, + velocity: 0, + openHeight: openHeight, + wasOpen: true + ) + ) + // The same reveal settles the other way depending on where the drag + // began, which is what makes an abandoned pull return to its own rest + // position instead of jumping across the screen. + let midway = openHeight / 2 + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: midway, velocity: 0, openHeight: openHeight, wasOpen: true + ) == false + ) + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: midway, velocity: 0, openHeight: openHeight, wasOpen: false + ) + ) + } + + @Test + func aFlickDecidesTheSettleRegardlessOfHowFarTheDrawerTravelled() { + let openHeight: CGFloat = 400 + // Short downward flick still opens. + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: 40, + velocity: 800, + openHeight: openHeight, + wasOpen: false + ) + ) + // Upward flick from a nearly open drawer still closes. + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: 380, + velocity: -1200, + openHeight: openHeight, + wasOpen: true + ) == false + ) + // An upward flick that is not enough to carry the drawer back past the + // commit distance leaves it open. + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: 380, + velocity: -200, + openHeight: openHeight, + wasOpen: true + ) + ) + // A downward flick on a closed drawer that dies immediately does not + // count as a swipe. + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: 10, + velocity: 100, + openHeight: openHeight, + wasOpen: false + ) == false + ) + } + + @Test + func theCommitDistanceNeverExceedsHalfOfAShortDrawer() { + // The keyboard floor can make the drawer shorter than the ordinary + // commit distance; both directions must still be reachable. + let short = FeatureCommandDrawerGeometry.minimumOpenHeight + let commit = FeatureCommandDrawerGeometry.commitDistance(openHeight: short) + #expect(commit <= short / 2) + + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: short / 2, velocity: 0, openHeight: short, wasOpen: false + ) + ) + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: short / 2, velocity: 0, openHeight: short, wasOpen: true + ) == false + ) + // No drawer to settle means nothing to open. + #expect( + FeatureCommandDrawerGeometry.settlesOpen( + reveal: 300, velocity: 900, openHeight: 0, wasOpen: false + ) == false + ) + } + + // MARK: - Gesture eligibility (issue #82) + + @Test + func onlyTheTopBarBandCanStartTheGestureWhileTheDrawerIsClosed() { + let topInset: CGFloat = 62 + + // Inside the top bar. + #expect( + FeatureCommandDrawerGesture.canBeginTouch(atY: 62, reveal: 0, topInset: topInset) + ) + #expect( + FeatureCommandDrawerGesture.canBeginTouch(atY: 110, reveal: 0, topInset: topInset) + ) + // Above the top bar is system chrome, not the app's drawer handle. + #expect( + FeatureCommandDrawerGesture.canBeginTouch(atY: 20, reveal: 0, topInset: topInset) + == false + ) + // The middle and bottom of the Home thread list must stay scrollable. + for y in [200, 320, 480, 700] as [CGFloat] { + #expect( + FeatureCommandDrawerGesture.canBeginTouch(atY: y, reveal: 0, topInset: topInset) + == false + ) + } + } + + @Test + func theGrabBandCoversEveryTopBarInTheAppWithoutReachingTheSystemsOwn() { + // Issue #122: the same swipe has to work everywhere, so the band must + // cover Home's own bar and the navigation bar the thread page and the + // other pushed surfaces use — both of which start at the top inset. + let topInset: CGFloat = 62 + let homeBarHeight: CGFloat = 49 + let navigationBarHeight: CGFloat = 44 + + for barHeight in [homeBarHeight, navigationBarHeight] { + for offset in stride(from: CGFloat(1), through: barHeight, by: 4) { + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: topInset + offset, reveal: 0, topInset: topInset + ) + ) + } + } + + // The status bar above the inset stays the system's: that is where the + // notification shade is pulled from, and the app must not claim it. + for y in stride(from: CGFloat(0), through: topInset - 1, by: 6) { + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: y, reveal: 0, topInset: topInset + ) == false + ) + } + + // Content below the bars keeps every ordinary scroll, on Home's thread + // list and on the thread transcript alike. + for y in stride(from: topInset + homeBarHeight + 64, through: 800, by: 40) { + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: y, reveal: 0, topInset: topInset + ) == false + ) + } + } + + @Test + func theOpenDrawerIsGrabbedByItsOwnEdgeInsteadOfTheTopOfTheScreen() { + let reveal: CGFloat = 420 + let topInset: CGFloat = 62 + // In window coordinates the open drawer's edge is below the inset. + let edge = topInset + reveal + + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: edge, reveal: reveal, topInset: topInset + ) + ) + // The handle just above the edge, and the scrim just below it. + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: edge - 20, reveal: reveal, topInset: topInset + ) + ) + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: edge + 50, reveal: reveal, topInset: topInset + ) + ) + // The drawer's own scrollable result list keeps its drags. + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: 240, reveal: reveal, topInset: topInset + ) == false + ) + // So does the page far below the drawer. + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: 700, reveal: reveal, topInset: topInset + ) == false + ) + // The top bar band no longer applies once the drawer is out. + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: topInset + 10, reveal: reveal, topInset: topInset + ) == false + ) + } + + @Test + func aPartlyRevealedDrawerNeverClaimsTheSystemStatusArea() { + let topInset: CGFloat = 62 + for reveal in stride(from: CGFloat(1), through: 27, by: 2) { + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: topInset - 1, + reveal: reveal, + topInset: topInset + ) == false + ) + #expect( + FeatureCommandDrawerGesture.canBeginTouch( + atY: topInset, + reveal: reveal, + topInset: topInset + ) + ) + } + } + + @Test @MainActor + func gestureUninstallCancelsOnlyAnActiveDragAndTakesScrollPriority() { + #expect( + FeatureCommandDrawerGestureView.cancelsActiveDragWhenUninstalled(state: .began) + ) + #expect( + FeatureCommandDrawerGestureView.cancelsActiveDragWhenUninstalled(state: .changed) + ) + #expect( + FeatureCommandDrawerGestureView.cancelsActiveDragWhenUninstalled(state: .ended) + == false + ) + + let scrollView = UIScrollView() + #expect( + FeatureCommandDrawerGestureView.takesPriority(over: scrollView.panGestureRecognizer) + ) + #expect( + FeatureCommandDrawerGestureView.takesPriority(over: UITapGestureRecognizer()) + == false + ) + } + + @Test @MainActor + func teardownCancellationCrossesTheCurrentViewUpdateBoundary() async { + var callbackRan = false + + await withCheckedContinuation { continuation in + FeatureCommandDrawerGestureView.afterCurrentViewUpdate { + callbackRan = true + continuation.resume() + } + #expect(callbackRan == false) + } + + #expect(callbackRan) + } + + @Test @MainActor + func gestureEligibilityRejectsModalTextEntryAndWrongWindowPaths() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + let host = UIView(frame: window.bounds) + let controller = UIViewController() + controller.view = host + window.rootViewController = controller + window.isHidden = false + defer { window.isHidden = true } + + let location = CGPoint(x: 20, y: 20) + #expect(FeatureCommandDrawerGestureView.canReceiveTouch( + view: host, + location: location, + window: window, + gestureHost: host, + reveal: 0, + topInset: 0, + hasPresentedViewController: false + )) + #expect(FeatureCommandDrawerGestureView.canReceiveTouch( + view: host, + location: location, + window: window, + gestureHost: host, + reveal: 0, + topInset: 0, + hasPresentedViewController: true + ) == false) + + let otherHost = UIView(frame: window.bounds) + #expect(FeatureCommandDrawerGestureView.canReceiveTouch( + view: host, + location: location, + window: window, + gestureHost: otherHost, + reveal: 0, + topInset: 0, + hasPresentedViewController: false + ) == false) + + let field = UITextField(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + host.addSubview(field) + #expect(FeatureCommandDrawerGestureView.canReceiveTouch( + view: field, + location: location, + window: window, + gestureHost: host, + reveal: 0, + topInset: 0, + hasPresentedViewController: false + ) == false) + } + + @Test + func theGestureClaimsOnlyClearlyVerticalMotionInTheRightDirection() { + // Downward pull opens. + #expect( + FeatureCommandDrawerGesture.shouldBegin( + velocity: CGPoint(x: 0, y: 600), + translation: .zero, + isOpen: false + ) + ) + // An upward drag on a closed drawer is not the command gesture. + #expect( + FeatureCommandDrawerGesture.shouldBegin( + velocity: CGPoint(x: 0, y: -600), + translation: .zero, + isOpen: false + ) == false + ) + // A mostly horizontal drag belongs to back-swipe and swipe actions. + #expect( + FeatureCommandDrawerGesture.shouldBegin( + velocity: CGPoint(x: 500, y: 200), + translation: .zero, + isOpen: false + ) == false + ) + // Real travel wins over the initial velocity estimate once it exists. + #expect( + FeatureCommandDrawerGesture.shouldBegin( + velocity: CGPoint(x: 0, y: -600), + translation: CGPoint(x: 2, y: 40), + isOpen: false + ) + ) + // An open drawer accepts the upward push that closes it. + #expect( + FeatureCommandDrawerGesture.shouldBegin( + velocity: CGPoint(x: 0, y: -600), + translation: .zero, + isOpen: true + ) + ) + } + + // MARK: - Presentation state + + @Test + func aCompletedPullOpensTheDrawerAtItsRestPosition() { + var state = FeatureCommandDrawerState() + #expect(state.isVisible == false) + + state.beginDrag() + state.updateDrag(translation: 120, openHeight: 400) + #expect(state.reveal == 120) + #expect(state.isDragging) + #expect(state.isOpen == false) + #expect(state.isVisible) + + state.updateDrag(translation: 300, openHeight: 400) + #expect(state.reveal == 300) + + let opened = state.endDrag(velocity: 0, openHeight: 400) + #expect(opened) + #expect(state.isOpen) + #expect(state.isDragging == false) + #expect(state.reveal == 400) + } + + @Test + func releasingBeforeTheCommitDistanceReturnsTheDrawerToTheClosedEdge() { + var state = FeatureCommandDrawerState() + state.beginDrag() + state.updateDrag(translation: 90, openHeight: 400) + + let opened = state.endDrag(velocity: 0, openHeight: 400) + #expect(opened == false) + #expect(state.isOpen == false) + #expect(state.reveal == 0) + #expect(state.isVisible == false) + } + + @Test + func aSecondDragStartsFromWhereTheDrawerAlreadyIs() { + var state = FeatureCommandDrawerState() + state.settle(open: true, openHeight: 400) + + state.beginDrag() + // The very first callback of a new drag must not move the drawer. + state.updateDrag(translation: 0, openHeight: 400) + #expect(state.reveal == 400) + + state.updateDrag(translation: -260, openHeight: 400) + #expect(state.reveal == 140) + let opened = state.endDrag(velocity: 0, openHeight: 400) + #expect(opened == false) + #expect(state.reveal == 0) + } + + @Test + func aCancelledPanReturnsToTheRestPositionItStartedFrom() { + var state = FeatureCommandDrawerState() + state.settle(open: true, openHeight: 400) + state.beginDrag() + state.updateDrag(translation: -200, openHeight: 400) + + state.cancelDrag(openHeight: 400) + #expect(state.isOpen) + #expect(state.reveal == 400) + #expect(state.isDragging == false) + } + + @Test + func dragUpdatesAreIgnoredUntilADragActuallyBegins() { + var state = FeatureCommandDrawerState() + state.updateDrag(translation: 300, openHeight: 400) + + #expect(state.reveal == 0) + let opened = state.endDrag(velocity: 900, openHeight: 400) + #expect(opened == false) + #expect(state.isOpen == false) + } + + @Test + func closingAndResizingKeepTheDrawerPinnedToItsEdge() { + var state = FeatureCommandDrawerState() + state.settle(open: true, openHeight: 400) + + // A keyboard or rotation change resizes the viewport under an open drawer. + state.synchronize(openHeight: 300) + #expect(state.reveal == 300) + #expect(state.isOpen) + + state.close() + #expect(state.isOpen == false) + #expect(state.reveal == 0) + + // Resizing a closed drawer leaves it closed. + state.synchronize(openHeight: 500) + #expect(state.reveal == 0) + } + + @Test + func aResizeDuringADragDoesNotFightTheFinger() { + var state = FeatureCommandDrawerState() + state.beginDrag() + state.updateDrag(translation: 150, openHeight: 400) + + state.synchronize(openHeight: 300) + #expect(state.reveal == 150) + #expect(state.isDragging) + } + + @MainActor + private final class TestResponder: FeatureCommandDrawerRestorableResponder { + var canRestoreCommandDrawerFocus: Bool + private(set) var restoreCount = 0 + + init(canRestore: Bool = true) { + canRestoreCommandDrawerFocus = canRestore + } + + func restoreCommandDrawerFocus() -> Bool { + guard canRestoreCommandDrawerFocus else { return false } + restoreCount += 1 + return true + } + } + + @MainActor + private final class TestViewController: UIViewController { + override var canBecomeFirstResponder: Bool { true } + } +} From 32a60bb8d239ef994a8dd2a28617e20c2a6d0410 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Thu, 27 Aug 2026 12:46:56 +1000 Subject: [PATCH 2/2] feat(swift-ios): present the command drawer catalog --- .../FeatureCommandDrawerCatalog.swift | 196 +++++++++ ...FeatureCommandDrawerPresentationView.swift | 387 ++++++++++++++++++ .../Features/Workspace/WorkspaceView.swift | 48 +++ ...eatureCommandDrawerPresentationTests.swift | 251 ++++++++++++ 4 files changed, 882 insertions(+) create mode 100644 apps/swift-ios/Features/Workspace/FeatureCommandDrawerCatalog.swift create mode 100644 apps/swift-ios/Features/Workspace/FeatureCommandDrawerPresentationView.swift create mode 100644 apps/swift-ios/Tests/FeatureTests/FeatureCommandDrawerPresentationTests.swift diff --git a/apps/swift-ios/Features/Workspace/FeatureCommandDrawerCatalog.swift b/apps/swift-ios/Features/Workspace/FeatureCommandDrawerCatalog.swift new file mode 100644 index 000000000000..20e3e9df62e8 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/FeatureCommandDrawerCatalog.swift @@ -0,0 +1,196 @@ +import Foundation + +enum FeatureCommandDrawerAction: String, CaseIterable, Equatable, Sendable { + case newTask + case addProject + case settings + case allProjects + + var title: String { + switch self { + case .newTask: "New task" + case .addProject: "Add project" + case .settings: "Settings" + case .allProjects: "Show all projects" + } + } + + var systemImage: String { + switch self { + case .newTask: "square.and.pencil" + case .addProject: "folder.badge.plus" + case .settings: "slider.horizontal.3" + case .allProjects: "line.3.horizontal.decrease" + } + } +} + +enum FeatureCommandDrawerItem: Identifiable, Equatable, Sendable { + case action(FeatureCommandDrawerAction) + case project(id: String, name: String) + case thread(id: String, title: String, projectName: String?) + + var id: String { + switch self { + case let .action(action): "action:\(action.rawValue)" + case let .project(id, _): "project:\(id)" + case let .thread(id, _, _): "thread:\(id)" + } + } + + var title: String { + switch self { + case let .action(action): action.title + case let .project(_, name): name + case let .thread(_, title, _): title + } + } + + var subtitle: String? { + switch self { + case .action: nil + case .project: "Filter Home to this project" + case let .thread(_, _, projectName): projectName + } + } + + var systemImage: String { + switch self { + case let .action(action): action.systemImage + case .project: "folder" + case .thread: "bubble.left.and.text.bubble.right" + } + } + + var destination: FeatureCommandDrawerDestination { + switch self { + case let .action(action): .action(action) + case let .project(id, _): .project(id: id) + case let .thread(id, _, _): .thread(id: id) + } + } +} + +/// A selection leaves the drawer through one typed workspace-routing seam. +/// The presentation owns this mapping; the workspace continues to own the +/// actual navigation and sheets behind each destination. +enum FeatureCommandDrawerDestination: Equatable, Sendable { + case action(FeatureCommandDrawerAction) + case project(id: String) + case thread(id: String) +} + +/// Resolves the state machine's reveal into the current presentation geometry. +/// +/// A settled drawer stays attached to its live edge when the keyboard changes +/// the available height. Dragging still uses the state machine's finger-bound +/// reveal, and beginning the next drag synchronizes that new rest position +/// back into the state machine. +enum FeatureCommandDrawerPresentationGeometry { + static func reveal( + state: FeatureCommandDrawerState, + measuredOpenHeight: CGFloat + ) -> CGFloat { + state.isOpen && !state.isDragging ? measuredOpenHeight : state.reveal + } +} + +enum FeatureCommandDrawerAccessibility { + static let drawerIdentifier = "command-drawer" + static let searchIdentifier = "command-drawer-search-field" + static let searchLabel = "Search commands" + static let emptyIdentifier = "command-drawer-empty" + static let scrimIdentifier = "command-drawer-scrim" + static let scrimLabel = "Close commands" + static let clearSearchLabel = "Clear command search" + + static func itemIdentifier(_ item: FeatureCommandDrawerItem) -> String { + "command-drawer-item-\(item.id)" + } + + static func drawerIsHidden(_ state: FeatureCommandDrawerState) -> Bool { + !state.isOpen + } + + static func workspaceIsHidden(_ state: FeatureCommandDrawerState) -> Bool { + state.isOpen + } + + static func scrimIsHidden(_ state: FeatureCommandDrawerState) -> Bool { + !state.isOpen + } +} + +/// Builds the drawer's rows from workspace data that is already loaded. +/// +/// This is a plain substring filter on purpose: the drawer's contribution is +/// the physical presentation, and ranked fuzzy search would be a separate +/// behavior with its own acceptance. +enum FeatureCommandDrawerCatalog { + static let threadLimit = 8 + static let projectLimit = 6 + + static func items( + projects: [FeatureProject], + threads: [FeatureThread], + selectedProjectID: String?, + query: String + ) -> [FeatureCommandDrawerItem] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + return actionItems(selectedProjectID: selectedProjectID, query: trimmed) + + threadItems(threads: threads, projects: projects, query: trimmed) + + projectItems(projects: projects, query: trimmed) + } + + private static func actionItems( + selectedProjectID: String?, + query: String + ) -> [FeatureCommandDrawerItem] { + var actions: [FeatureCommandDrawerAction] = [.newTask, .addProject, .settings] + if selectedProjectID != nil { + actions.insert(.allProjects, at: 0) + } + return actions + .filter { matches($0.title, query: query) } + .map(FeatureCommandDrawerItem.action) + } + + private static func threadItems( + threads: [FeatureThread], + projects: [FeatureProject], + query: String + ) -> [FeatureCommandDrawerItem] { + let names = Dictionary( + projects.map { ($0.id, $0.name) }, + uniquingKeysWith: { first, _ in first } + ) + return threads + .filter { !$0.isArchived && matches($0.title, query: query) } + .sorted { + let left = activity(of: $0) + let right = activity(of: $1) + return left == right ? $0.id < $1.id : left > right + } + .prefix(threadLimit) + .map { .thread(id: $0.id, title: $0.title, projectName: names[$0.projectID]) } + } + + private static func projectItems( + projects: [FeatureProject], + query: String + ) -> [FeatureCommandDrawerItem] { + projects + .filter { matches($0.name, query: query) } + .prefix(projectLimit) + .map { .project(id: $0.id, name: $0.name) } + } + + private static func activity(of thread: FeatureThread) -> Date { + thread.lastActivityAt ?? thread.updatedAt + } + + private static func matches(_ candidate: String, query: String) -> Bool { + guard !query.isEmpty else { return true } + return candidate.localizedCaseInsensitiveContains(query) + } +} diff --git a/apps/swift-ios/Features/Workspace/FeatureCommandDrawerPresentationView.swift b/apps/swift-ios/Features/Workspace/FeatureCommandDrawerPresentationView.swift new file mode 100644 index 000000000000..d635cd8d524c --- /dev/null +++ b/apps/swift-ios/Features/Workspace/FeatureCommandDrawerPresentationView.swift @@ -0,0 +1,387 @@ +import SwiftUI +import UIKit + +/// Hosts the workspace inside a physical top drawer. +/// +/// The drawer hangs above the top edge; pulling it down moves the drawer, the +/// scrim, and the whole page together with the finger, and releasing settles +/// both to the same rest position. Nothing here presents a sheet, so the +/// palette can never animate up from the bottom while the finger travels down. +struct FeatureCommandDrawerContainer: View { + @SwiftUI.Environment(\.accessibilityReduceMotion) private var reduceMotion + + @Binding var state: FeatureCommandDrawerState + @Binding var query: String + @Binding var restoresPriorResponderOnClose: Bool + let items: [FeatureCommandDrawerItem] + let onSelect: (FeatureCommandDrawerItem) -> Void + @ViewBuilder let content: Content + + @FocusState private var isQueryFocused: Bool + @State private var responderOwnership = FeatureCommandDrawerResponderOwnership() + @State private var hasRenewedSearchFocusAfterSettle = false + + /// Measured in the drawer layer, which is the only place that needs + /// geometry. The workspace itself stays in its normal layout path: wrapping + /// a `NavigationSplitView` in a `GeometryReader` changes how it resolves its + /// own columns and safe areas, so the drawer never does that. + @State private var openHeight: CGFloat = 0 + /// Height the software keyboard currently covers, so the drawer can rest + /// exactly on top of it instead of hiding behind it. + @State private var keyboardHeight: CGFloat = 0 + + var body: some View { + // The page is deliberately not offset. The drawer is presented over the + // workspace, so the rows, header and composer underneath stay exactly + // where they were and only the drawer and its scrim move with the + // finger. Translating the page as well read as the whole screen being + // shoved downwards, which is not what a drawer does. + presentedContent + .overlay { + scrim(progress: progress) + } + .overlay(alignment: .top) { + drawerLayer + } + .background { + FeatureCommandDrawerGestureView( + reveal: FeatureCommandDrawerPresentationGeometry.reveal( + state: state, + measuredOpenHeight: openHeight + ), + isOpen: state.isOpen, + onBegan: { responder in + responderOwnership.begin(from: responder) + state.synchronize(openHeight: openHeight) + state.beginDrag() + }, + onChanged: { translation in + state.updateDrag(translation: translation, openHeight: openHeight) + }, + onEnded: { velocity in + settle(velocity: velocity, openHeight: openHeight) + }, + onCancelled: { + withAnimation(settleAnimation) { + state.cancelDrag(openHeight: openHeight) + } + responderOwnership.cancel(returningToOpen: state.isOpen) + } + ) + } + // Focus follows presentation: the keyboard rises with the drawer so + // typing is instant, and the drawer's open height already accounts + // for it before the drag is released. + .onChange(of: state.isVisible) { _, isVisible in + isQueryFocused = FeatureCommandDrawerFocus.searchIsFocused(for: state) + if !isVisible { + query = "" + hasRenewedSearchFocusAfterSettle = false + responderOwnership.close( + restoringPrior: restoresPriorResponderOnClose + ) + restoresPriorResponderOnClose = true + } + } + // …but the request above is made while the field is still above the + // window's top edge, where it can be dropped. Renew it whenever the + // drawer is open and nothing has taken focus, which covers a swipe + // that settles before the field was ever on screen and any other + // path that opens the drawer without a drag. + .onChange(of: state.isOpen) { _, isOpen in + if !isOpen { + hasRenewedSearchFocusAfterSettle = false + } + renewSearchFocusIfNeeded() + } + .onReceive( + NotificationCenter.default.publisher( + for: UIResponder.keyboardWillChangeFrameNotification + ) + ) { note in + applyKeyboardFrame(from: note) + } + .onReceive( + NotificationCenter.default.publisher( + for: UIResponder.keyboardWillHideNotification + ) + ) { _ in + keyboardHeight = 0 + } + } + + @ViewBuilder + private var presentedContent: some View { + if FeatureCommandDrawerAccessibility.workspaceIsHidden(state) { + // NavigationSplitView hosts UIKit accessibility descendants that a + // dynamic hidden modifier alone can leave exposed. Collapse those + // descendants into this boundary before hiding it. + content + .accessibilityElement(children: .ignore) + .accessibilityHidden(true) + } else { + content + } + } + + /// The reported frame is in screen coordinates. Measure it against this + /// app's key window so another scene, or an undocked keyboard, cannot + /// shorten the drawer. + private func applyKeyboardFrame(from note: Notification) { + guard let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, + let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .filter({ $0.activationState == .foregroundActive }) + .flatMap(\.windows) + .first(where: \.isKeyWindow) + else { return } + let windowFrame = window.screen.coordinateSpace.convert(window.bounds, from: window) + keyboardHeight = FeatureCommandDrawerGeometry.keyboardOverlap( + keyboardFrame: frame, + windowFrame: windowFrame + ) + } + + private var progress: CGFloat { + FeatureCommandDrawerGeometry.progress( + reveal: FeatureCommandDrawerPresentationGeometry.reveal( + state: state, + measuredOpenHeight: openHeight + ), + openHeight: openHeight + ) + } + + private var drawerLayer: some View { + GeometryReader { proxy in + // Deliberately laid out inside the page rather than under + // `ignoresSafeArea`: there the proxy reports a zero top inset and the + // drawer's own content ends up beneath the status bar. Here the + // proxy reports the real inset and the page's height, and the drawer + // still reaches the window's top edge because overlays do not clip. + let topInset = proxy.safeAreaInsets.top + let measured = FeatureCommandDrawerGeometry.openHeight( + availableHeight: proxy.size.height, + keyboardHeight: keyboardHeight, + bottomInset: proxy.safeAreaInsets.bottom + ) + + drawer(openHeight: measured, topInset: topInset) + .offset( + y: FeatureCommandDrawerGeometry.drawerOffset( + reveal: FeatureCommandDrawerPresentationGeometry.reveal( + state: state, + measuredOpenHeight: measured + ), + openHeight: measured, + topInset: topInset + ) + ) + .opacity(state.isVisible ? 1 : 0) + .accessibilityHidden(FeatureCommandDrawerAccessibility.drawerIsHidden(state)) + .onChange(of: measured, initial: true) { _, height in + openHeight = height + state.synchronize(openHeight: height) + } + } + // The drawer sizes itself against the keyboard explicitly, so it must + // measure the page at its full height. Letting SwiftUI's keyboard + // avoidance shrink this layer too would subtract the keyboard twice and + // leave a band of the page showing between drawer and keyboard. + .ignoresSafeArea(.keyboard) + .allowsHitTesting(state.isVisible) + } + + private var settleAnimation: Animation { + reduceMotion + ? .easeOut(duration: 0.2) + : .spring(response: 0.32, dampingFraction: 0.86) + } + + private func settle(velocity: CGFloat, openHeight: CGFloat) { + withAnimation(settleAnimation) { + let opens = state.endDrag(velocity: velocity, openHeight: openHeight) + responderOwnership.settle(open: opens) + } completion: { + // The last chance to focus, once the drawer has physically arrived: + // a swipe can settle open before the search field was ever on + // screen, and a request made then is dropped with no further state + // change to retry from. + renewSearchFocusIfNeeded() + } + } + + private func renewSearchFocusIfNeeded() { + guard FeatureCommandDrawerFocus.needsFocusRenewal( + state: state, + isFocused: isQueryFocused, + hasRenewedAfterSettle: hasRenewedSearchFocusAfterSettle + ) else { return } + hasRenewedSearchFocusAfterSettle = true + isQueryFocused = true + } + + private func close(restoringPrior: Bool = true) { + responderOwnership.close(restoringPrior: restoringPrior) + restoresPriorResponderOnClose = true + withAnimation(settleAnimation) { + state.close() + } + } + + @ViewBuilder + private func scrim(progress: CGFloat) -> some View { + if FeatureCommandDrawerAccessibility.scrimIsHidden(state) { + scrimSurface(progress: progress) + .accessibilityHidden(true) + } else { + scrimSurface(progress: progress) + .accessibilityElement() + .accessibilityAddTraits(.isButton) + .accessibilityLabel(FeatureCommandDrawerAccessibility.scrimLabel) + .accessibilityIdentifier(FeatureCommandDrawerAccessibility.scrimIdentifier) + } + } + + private func scrimSurface(progress: CGFloat) -> some View { + Color.black + .opacity(0.34 * progress) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { close() } + .allowsHitTesting(state.isOpen) + } + + private func drawer(openHeight: CGFloat, topInset: CGFloat) -> some View { + VStack(spacing: 0) { + searchField + .padding(.horizontal, 12) + .padding(.top, 6) + resultList + handle + } + // The layer spans the window, so the drawer reaches from the window's + // top edge down to its own edge and its bottom lands on the page top. + .padding(.top, topInset) + .frame(height: topInset + openHeight, alignment: .top) + .frame(maxWidth: .infinity) + // Two layers: the palette surface must be fully opaque so no page + // content shows through the area the drawer is meant to cover. + .background(T3Colors.sheet) + .background(T3Colors.background) + .overlay(alignment: .bottom) { + Rectangle() + .fill(T3Colors.border) + .frame(height: 1) + } + .shadow(color: T3Colors.shadow, radius: 18, y: 8) + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isModal) + .accessibilityIdentifier(FeatureCommandDrawerAccessibility.drawerIdentifier) + } + + private var searchField: some View { + HStack(spacing: 9) { + Image(systemName: "command") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(T3Colors.textTertiary) + TextField("Search commands, tasks, projects", text: $query) + .font(.subheadline) + .foregroundStyle(T3Colors.textPrimary) + .focused($isQueryFocused) + .submitLabel(.search) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityLabel(FeatureCommandDrawerAccessibility.searchLabel) + .accessibilityIdentifier(FeatureCommandDrawerAccessibility.searchIdentifier) + if !query.isEmpty { + Button { query = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(T3Colors.textTertiary) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .accessibilityLabel(FeatureCommandDrawerAccessibility.clearSearchLabel) + } + } + .padding(.horizontal, 12) + .frame(height: T3Metrics.minimumTapTarget) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(T3Colors.border, lineWidth: 1) + } + } + + @ViewBuilder + private var resultList: some View { + if items.isEmpty { + VStack(spacing: 6) { + Text("No matches") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + Text("Try a different search.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier(FeatureCommandDrawerAccessibility.emptyIdentifier) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(items) { item in + row(item) + } + } + .padding(.vertical, 4) + } + // The keyboard is part of the open drawer's layout; dropping it + // while scrolling results would resize the drawer mid-scroll. + .scrollDismissesKeyboard(.never) + } + } + + private func row(_ item: FeatureCommandDrawerItem) -> some View { + Button { + close(restoringPrior: false) + onSelect(item) + } label: { + HStack(spacing: 11) { + Image(systemName: item.systemImage) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(T3Colors.textTertiary) + .frame(width: 22) + VStack(alignment: .leading, spacing: 1) { + Text(item.title) + .font(.subheadline) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + if let subtitle = item.subtitle { + Text(subtitle) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .frame(minHeight: T3Metrics.minimumTapTarget, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(item.title) + .accessibilityValue(item.subtitle ?? "") + .accessibilityIdentifier(FeatureCommandDrawerAccessibility.itemIdentifier(item)) + } + + private var handle: some View { + Capsule() + .fill(T3Colors.textTertiary.opacity(0.45)) + .frame(width: 38, height: 5) + .frame(maxWidth: .infinity) + .frame(height: FeatureCommandDrawerGesture.handleGrabHeight) + .contentShape(Rectangle()) + .accessibilityHidden(true) + } +} diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index abc564494a81..3ba63f2dd969 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -45,6 +45,9 @@ public struct WorkspaceView: View { @State private var sidebarBoundaryNow = Date.now @State private var preferredCompactColumn = NavigationSplitViewColumn.sidebar @State private var homePresentationCache = HomePresentationCache() + @State private var commandDrawer = FeatureCommandDrawerState() + @State private var commandDrawerQuery = "" + @State private var commandDrawerRestoresPriorResponderOnClose = true @FocusState private var isSearchFocused: Bool public init( @@ -115,6 +118,18 @@ public struct WorkspaceView: View { } public var body: some View { + FeatureCommandDrawerContainer( + state: $commandDrawer, + query: $commandDrawerQuery, + restoresPriorResponderOnClose: $commandDrawerRestoresPriorResponderOnClose, + items: commandDrawerItems, + onSelect: selectCommand + ) { + workspace + } + } + + private var workspace: some View { NavigationSplitView(preferredCompactColumn: $preferredCompactColumn) { sidebar .navigationSplitViewColumnWidth( @@ -602,6 +617,35 @@ public struct WorkspaceView: View { preferredCompactColumn = .sidebar } + private var commandDrawerItems: [FeatureCommandDrawerItem] { + FeatureCommandDrawerCatalog.items( + projects: model.snapshot.projects, + threads: model.snapshot.threads, + selectedProjectID: selectedProjectID, + query: commandDrawerQuery + ) + } + + /// The drawer only routes into presentation the workspace already owns. + private func selectCommand(_ item: FeatureCommandDrawerItem) { + isSearchFocused = false + switch item.destination { + case let .thread(id): + openThread(id) + case let .project(id): + selectedProjectID = id + closeSelectedThread() + case .action(.allProjects): + selectedProjectID = nil + case .action(.newTask): + openNewTaskOrProjectCreation() + case .action(.addProject): + showingAddProject = true + case .action(.settings): + showingSettings = true + } + } + @MainActor private func openProjectCreation() { showingNewTask = false @@ -648,6 +692,10 @@ public struct WorkspaceView: View { } private func dismissTransientPresentations() { + if commandDrawer.isVisible { + commandDrawerRestoresPriorResponderOnClose = false + commandDrawer.close() + } showingNewTask = false showingAddProject = false showingEnvironments = false diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureCommandDrawerPresentationTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureCommandDrawerPresentationTests.swift new file mode 100644 index 000000000000..9432abb16e15 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureCommandDrawerPresentationTests.swift @@ -0,0 +1,251 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Command drawer presentation and integration") +struct FeatureCommandDrawerPresentationTests { + @Test + func catalogOffersWorkspaceActionsThenRecentThreadsAndProjects() { + let items = FeatureCommandDrawerCatalog.items( + projects: [project("alpha", name: "Alpha")], + threads: [ + thread("old", projectID: "alpha", title: "Older task", activity: 10), + thread("new", projectID: "alpha", title: "Newer task", activity: 20), + ], + selectedProjectID: nil, + query: "" + ) + + #expect( + items.map(\.id) == [ + "action:newTask", + "action:addProject", + "action:settings", + "thread:new", + "thread:old", + "project:alpha", + ] + ) + } + + @Test + func clearingTheProjectFilterIsOfferedOnlyWhileAFilterIsApplied() { + let projects = [project("alpha", name: "Alpha")] + + #expect( + FeatureCommandDrawerCatalog.items( + projects: projects, + threads: [], + selectedProjectID: "alpha", + query: "" + ).first?.id == "action:allProjects" + ) + #expect( + FeatureCommandDrawerCatalog.items( + projects: projects, + threads: [], + selectedProjectID: nil, + query: "" + ).contains { $0.id == "action:allProjects" } == false + ) + } + + @Test + func queryFiltersActionsThreadsAndProjectsTogether() { + let items = FeatureCommandDrawerCatalog.items( + projects: [project("alpha", name: "Alpha"), project("beta", name: "Beta")], + threads: [ + thread("a", projectID: "alpha", title: "Ship the alpha drawer", activity: 30), + thread("b", projectID: "beta", title: "Unrelated", activity: 40), + ], + selectedProjectID: nil, + query: " ALPHA " + ) + + #expect(items.map(\.id) == ["thread:a", "project:alpha"]) + } + + @Test + func archivedThreadsStayOutAndCatalogResultsAreBounded() { + let threads = (0..<20).map { + thread("t\($0)", projectID: "alpha", title: "Task \($0)", activity: TimeInterval($0)) + } + [ + thread( + "gone", + projectID: "alpha", + title: "Task archived", + activity: 999, + isArchived: true + ), + ] + + let items = FeatureCommandDrawerCatalog.items( + projects: (0..<10).map { project("p\($0)", name: "Project \($0)") }, + threads: threads, + selectedProjectID: nil, + query: "" + ) + + let threadIDs = items.compactMap { item -> String? in + guard case let .thread(id, _, _) = item else { return nil } + return id + } + let projectIDs = items.compactMap { item -> String? in + guard case let .project(id, _) = item else { return nil } + return id + } + #expect(threadIDs.count == FeatureCommandDrawerCatalog.threadLimit) + #expect(threadIDs.first == "t19") + #expect(threadIDs.contains("gone") == false) + #expect(projectIDs.count == FeatureCommandDrawerCatalog.projectLimit) + } + + @Test + func threadRowsCarryTheirProjectNameForDisambiguation() { + let items = FeatureCommandDrawerCatalog.items( + projects: [project("alpha", name: "Alpha")], + threads: [ + thread("known", projectID: "alpha", title: "Known", activity: 20), + thread("orphan", projectID: "missing", title: "Orphan", activity: 10), + ], + selectedProjectID: nil, + query: "n" + ) + + #expect( + items.contains { $0 == .thread(id: "known", title: "Known", projectName: "Alpha") } + ) + #expect( + items.contains { $0 == .thread(id: "orphan", title: "Orphan", projectName: nil) } + ) + } + + @Test + func everyItemMapsToTheTypedWorkspaceDestinationItOwns() { + #expect( + FeatureCommandDrawerItem.thread( + id: "thread-1", + title: "Thread", + projectName: "Project" + ).destination == .thread(id: "thread-1") + ) + #expect( + FeatureCommandDrawerItem.project(id: "project-1", name: "Project").destination + == .project(id: "project-1") + ) + for action in FeatureCommandDrawerAction.allCases { + #expect(FeatureCommandDrawerItem.action(action).destination == .action(action)) + } + } + + @Test + func presentationMetadataNamesEveryRowWithoutLosingContext() { + let thread = FeatureCommandDrawerItem.thread( + id: "thread-1", + title: "Fix focus", + projectName: "T3 Code" + ) + let project = FeatureCommandDrawerItem.project(id: "project-1", name: "T3 Code") + + #expect(thread.title == "Fix focus") + #expect(thread.subtitle == "T3 Code") + #expect(thread.systemImage == "bubble.left.and.text.bubble.right") + #expect(project.title == "T3 Code") + #expect(project.subtitle == "Filter Home to this project") + #expect(project.systemImage == "folder") + for action in FeatureCommandDrawerAction.allCases { + let item = FeatureCommandDrawerItem.action(action) + #expect(item.title.isEmpty == false) + #expect(item.systemImage.isEmpty == false) + #expect(item.subtitle == nil) + } + } + + @Test + func accessibilityExposureIsStableUniqueAndOnlyVisibleAtTheOpenRestState() { + let items: [FeatureCommandDrawerItem] = [ + .action(.settings), + .project(id: "project-1", name: "T3 Code"), + .thread(id: "thread-1", title: "Fix focus", projectName: "T3 Code"), + ] + let identifiers = items.map(FeatureCommandDrawerAccessibility.itemIdentifier) + + #expect(Set(identifiers).count == items.count) + #expect(identifiers == [ + "command-drawer-item-action:settings", + "command-drawer-item-project:project-1", + "command-drawer-item-thread:thread-1", + ]) + #expect(FeatureCommandDrawerAccessibility.searchLabel == "Search commands") + #expect(FeatureCommandDrawerAccessibility.scrimLabel == "Close commands") + + var state = FeatureCommandDrawerState() + #expect(FeatureCommandDrawerAccessibility.drawerIsHidden(state)) + #expect(FeatureCommandDrawerAccessibility.scrimIsHidden(state)) + #expect(FeatureCommandDrawerAccessibility.workspaceIsHidden(state) == false) + state.beginDrag() + state.updateDrag(translation: 100, openHeight: 500) + #expect(FeatureCommandDrawerAccessibility.drawerIsHidden(state)) + #expect(FeatureCommandDrawerAccessibility.scrimIsHidden(state)) + #expect(FeatureCommandDrawerAccessibility.workspaceIsHidden(state) == false) + state.settle(open: true, openHeight: 500) + #expect(FeatureCommandDrawerAccessibility.drawerIsHidden(state) == false) + #expect(FeatureCommandDrawerAccessibility.scrimIsHidden(state) == false) + #expect(FeatureCommandDrawerAccessibility.workspaceIsHidden(state)) + state.close() + #expect(FeatureCommandDrawerAccessibility.drawerIsHidden(state)) + #expect(FeatureCommandDrawerAccessibility.scrimIsHidden(state)) + #expect(FeatureCommandDrawerAccessibility.workspaceIsHidden(state) == false) + } + + @Test + func settledPresentationTracksKeyboardResizesWithoutBreakingFingerBoundDrags() { + var state = FeatureCommandDrawerState() + state.settle(open: true, openHeight: 480) + + #expect( + FeatureCommandDrawerPresentationGeometry.reveal( + state: state, + measuredOpenHeight: 720 + ) == 720 + ) + + state.beginDrag() + state.updateDrag(translation: -40, openHeight: 720) + #expect( + FeatureCommandDrawerPresentationGeometry.reveal( + state: state, + measuredOpenHeight: 720 + ) == 440 + ) + + state.close() + #expect( + FeatureCommandDrawerPresentationGeometry.reveal( + state: state, + measuredOpenHeight: 720 + ) == 0 + ) + } + + private func project(_ id: String, name: String) -> FeatureProject { + FeatureProject(id: id, environmentID: "env", name: name, path: "/tmp/\(id)") + } + + private func thread( + _ id: String, + projectID: String, + title: String, + activity: TimeInterval, + isArchived: Bool = false + ) -> FeatureThread { + FeatureThread( + id: id, + projectID: projectID, + title: title, + updatedAt: Date(timeIntervalSince1970: activity), + isArchived: isArchived, + lastActivityAt: Date(timeIntervalSince1970: activity) + ) + } +}