diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift index 6760beae3f78..ac253cdabdbb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift @@ -104,6 +104,17 @@ extension WorkspaceDetailView { // avoidance; otherwise the view ALSO shrinks for the keyboard // and the reservation double-counts (extra gap when open). .ignoresSafeArea(.keyboard, edges: .bottom) + // The surface's frame must be KEYBOARD-INVARIANT: with only the keyboard + // inset ignored, the bottom edge respects the home indicator while the + // keyboard is down but extends to the window bottom while it is up + // (the keyboard region subsumes the indicator inset), so the frame + // breathed by the indicator height on every transition and every + // frame-relative keyboard conversion inside the surface was that much + // off mid-animation (the bars visibly popped by it after each rise — + // device forensics 2026-08-06). Extending under the indicator in ALL + // states pins the frame; the dock's required safe-area cap keeps the + // bars clear of the indicator while the keyboard is down. + .ignoresSafeArea(.container, edges: .bottom) // Keep the grid clear of the Dynamic Island and nav bar. .padding(.top, terminalTopPadding) } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift index e7dfc1f447ab..a68e4d59f35a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift @@ -1,41 +1,73 @@ -import CmuxMobileShellModel import Testing @testable import CmuxMobileShellUI +/// Startup admission contract for an explicitly injected attach URL. +/// +/// The original version of this suite was written against a +/// `connectInjectedAttach(_:attachURL:_:)` coordinator API that was reworked +/// before it merged, so the file has not compiled since it landed — and a +/// compile-broken test target blocks every `cmux.xctestplan` run (the plan +/// builds all of its targets even under `-only-testing`). These tests assert +/// the same admission contract against the coordinator API that shipped; the +/// URL-connect side effects live in `CMUXMobileRootView`, which claims and +/// finishes attempts through exactly these entry points. @Suite struct MobileInjectedAttachStartupTests { @Test @MainActor - func beginsRouteAdmissionWithoutAnExternalTransportReadinessBarrier() async throws { + func connectedInjectedAttachConsumesStartupWithoutFallback() throws { let coordinator = MobileStartupConnectionCoordinator() let attempt = try #require(coordinator.claimInjectedAttach()) - let recorder = MobileInjectedAttachURLRecorder() - let attachURL = "cmux-ios://attach?v=2&payload=iroh-route" - - let completion = await coordinator.connectInjectedAttach( - attempt, - attachURL: attachURL - ) { rawURL in - await recorder.record(rawURL) - return MobilePairingURLConnectionResult.connected - } - - let completedAttempt = try #require(completion) - #expect(await recorder.values() == [attachURL]) - #expect(completedAttempt.result == .connected) - #expect(!completedAttempt.shouldReconnectStoredMac) + + let shouldFallBack = coordinator.finishInjectedAttach(attempt, outcome: .connected) + + #expect(!shouldFallBack) + #expect(!coordinator.shouldFallBackFromInjectedAttach) + // A consumed explicit route keeps owning startup: the saved-Mac + // reconnect must not also dial. #expect(coordinator.claimStoredReconnect() == nil) } -} -private actor MobileInjectedAttachURLRecorder { - private var urls: [String] = [] + @Test + @MainActor + func approvalGatedInjectedAttachConsumesStartupWithoutFallback() throws { + let coordinator = MobileStartupConnectionCoordinator() + let attempt = try #require(coordinator.claimInjectedAttach()) + + let shouldFallBack = coordinator.finishInjectedAttach( + attempt, outcome: .awaitingUserApproval + ) + + #expect(!shouldFallBack) + #expect(!coordinator.shouldFallBackFromInjectedAttach) + // An attach parked on the Mac-side approval prompt still owns startup: + // the saved-Mac reconnect dialing underneath it would race the very + // connection the user is approving. + #expect(coordinator.claimStoredReconnect() == nil) + } + + @Test + @MainActor + func failedInjectedAttachReleasesStartupToStoredReconnect() throws { + let coordinator = MobileStartupConnectionCoordinator() + let attempt = try #require(coordinator.claimInjectedAttach()) + + let shouldFallBack = coordinator.finishInjectedAttach(attempt, outcome: .failed) - func record(_ url: String) { - urls.append(url) + #expect(shouldFallBack) + #expect(coordinator.shouldFallBackFromInjectedAttach) + // A failed explicit route releases startup so the authenticated shell + // is not stranded disconnected. + #expect(coordinator.claimStoredReconnect() != nil) } - func values() -> [String] { - urls + @Test + @MainActor + func onlyOneStartupSourceCanClaimAdmission() throws { + let coordinator = MobileStartupConnectionCoordinator() + + #expect(coordinator.claimInjectedAttach() != nil) + #expect(coordinator.claimInjectedAttach() == nil) + #expect(coordinator.claimStoredReconnect() == nil) } } diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift new file mode 100644 index 000000000000..e954eb7fcd73 --- /dev/null +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift @@ -0,0 +1,116 @@ +#if canImport(UIKit) +public import UIKit + +/// Process-wide record of the software keyboard's most recent frame transition. +/// +/// `UIView.keyboardLayoutGuide` only reflects keyboard changes UIKit routed to +/// that view's window while the view was installed; a view (re)attached around +/// a workspace switch can miss the transition entirely and stay seated on the +/// guide's bottom-safe-area fallback while the keyboard is up. Keyboard +/// notifications, by contrast, are posted process-wide regardless of any +/// view's attachment, so this tracker is always able to answer "where is the +/// keyboard now?" for late-attaching views. It is a read-only catch-up source: +/// guide-constrained chrome keeps following the guide, and consumers use the +/// tracker only to bound how far below the keyboard that chrome may sit. +@MainActor +public final class MobileKeyboardFrameTracker { + /// The single process-wide tracker. Created on first access; access it + /// before the first keyboard presentation so no transition is missed. + /// The keyboard is process-global UIKit state, and a late-created view + /// must read transitions observed BEFORE it existed — a per-instance + /// observer cannot provide that by construction. Consumers hold an + /// injectable reference (`GhosttySurfaceView.keyboardFrameTracker`), so + /// tests isolate with a private-center instance and never touch this one. + // lint:allow singleton — process-global keyboard state, injectable at use sites. + public static let shared = MobileKeyboardFrameTracker() + + /// The most recent keyboard transition, or `nil` while the keyboard state + /// is unknown (nothing observed yet, keyboard fully hidden, a transition + /// posted without a readable end frame, or state discarded on + /// backgrounding because iOS can tear the keyboard down without a paired + /// notification). + public private(set) var latestTransition: MobileKeyboardTransition? + + private nonisolated(unsafe) var tokens: [NSObjectProtocol] = [] + private nonisolated let notificationCenter: NotificationCenter + + /// Creates a tracker subscribed to the keyboard frame notifications. + /// + /// - Parameter notificationCenter: The center to observe; tests inject a + /// private center so posted fixtures cannot leak into other suites. + public init(notificationCenter: NotificationCenter = .default) { + self.notificationCenter = notificationCenter + tokens = [ + notificationCenter.addObserver( + forName: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + queue: .main + ) { [weak self] notification in + let transition = MobileKeyboardTransition(notification: notification) + MainActor.assumeIsolated { + // A transition without a readable end frame leaves the + // keyboard state unknown; fail closed (clear) so a stale + // floor can never keep the dock and viewport raised after + // the keyboard actually changed. + self?.latestTransition = transition + } + }, + // The settled twin of willChangeFrame: consumers converting the + // end frame through a view whose own frame moved during the + // transition re-derive against final geometry from this one. + notificationCenter.addObserver( + forName: UIResponder.keyboardDidChangeFrameNotification, + object: nil, + queue: .main + ) { [weak self] notification in + let transition = MobileKeyboardTransition(notification: notification) + MainActor.assumeIsolated { + self?.latestTransition = transition + } + }, + notificationCenter.addObserver( + forName: UIResponder.keyboardDidHideNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.latestTransition = nil } + }, + notificationCenter.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.latestTransition = nil } + }, + ] + } + + deinit { + for token in tokens { + notificationCenter.removeObserver(token) + } + } + + /// Returns how much of `view` the tracked keyboard covers from its bottom + /// edge, or zero while the keyboard state is unknown, the view is detached, + /// or the keyboard does not reach the view's bottom (floating/split iPad + /// keyboards, or an end frame parked below the screen after a dismissal). + public func overlap(in view: UIView) -> CGFloat { + latestTransition?.overlap(in: view) ?? 0 + } + + /// Returns how much of `view`'s window the tracked keyboard covers, in + /// window points — stable across a transition even when the view's own + /// frame animates with it. Zero while unknown or detached. + public func overlapInWindow(of view: UIView) -> CGFloat { + latestTransition?.overlapInWindow(of: view) ?? 0 + } + + /// Returns whether the tracked keyboard is visible to `view` (including + /// floating/split iPad keyboards that reserve no bottom space), or false + /// while the keyboard state is unknown or the view is detached. + public func isVisible(in view: UIView) -> Bool { + latestTransition?.isVisible(in: view) ?? false + } +} +#endif diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift index d95307f173fc..75eb3801c0ae 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift @@ -46,6 +46,26 @@ public struct MobileKeyboardTransition: Sendable { ).height } + /// Returns how much of `view`'s WINDOW is covered by the keyboard's final + /// frame, in window points. + /// + /// Window-space overlap is stable across a keyboard transition even when + /// the view's own frame animates with it (safe-area propagation can resize + /// hosted views mid-transition), so constraints anchored to the window can + /// be seeded from the first notification without a late correction. + /// + /// - Parameter view: The view whose window should be measured. + /// - Returns: The bottom overlap in window coordinates, or zero when the + /// view is detached or the keyboard does not reach the window's bottom. + @MainActor public func overlapInWindow(of view: UIView) -> CGFloat { + guard let window = view.window else { return 0 } + let keyboardFrameInWindow = window.convert(endFrame, from: nil) + return MobileKeyboardReservation( + keyboardFrameInWindow: keyboardFrameInWindow, + viewFrameInWindow: window.bounds + ).height + } + /// Returns whether the keyboard is visible to `view`, including floating or /// split iPad keyboards that do not reserve bottom layout space. @MainActor public func isVisible(in view: UIView) -> Bool { diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift new file mode 100644 index 000000000000..aa4afd609205 --- /dev/null +++ b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift @@ -0,0 +1,151 @@ +import Testing +import UIKit + +@testable import CmuxMobileSupport + +@MainActor +@Suite struct MobileKeyboardFrameTrackerTests { + /// Builds the keyboard notification shape UIKit posts, on a private center + /// so fixtures never leak into other suites. + private func post( + _ name: Notification.Name, + endFrame: CGRect? = nil, + to center: NotificationCenter + ) { + var userInfo: [AnyHashable: Any] = [:] + if let endFrame { + userInfo[UIResponder.keyboardFrameEndUserInfoKey] = endFrame + } + center.post(Notification(name: name, object: nil, userInfo: userInfo)) + } + + @Test func willChangeFrameRecordsOverlapForAnAttachedView() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + let view = UIView(frame: window.bounds) + window.addSubview(view) + window.isHidden = false + defer { window.isHidden = true } + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + + #expect(abs(tracker.overlap(in: view) - 300) <= 0.5) + } + + @Test func detachedViewReportsZeroOverlapUntilAttached() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + let view = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + + // The transition is recorded even though no view can resolve it yet; + // that is the whole point of the tracker. + #expect(tracker.overlap(in: view) == 0) + #expect(tracker.latestTransition != nil) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + window.addSubview(view) + window.isHidden = false + defer { window.isHidden = true } + + #expect(abs(tracker.overlap(in: view) - 300) <= 0.5) + } + + @Test func offscreenEndFrameReportsZeroOverlap() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + let view = UIView(frame: window.bounds) + window.addSubview(view) + window.isHidden = false + defer { window.isHidden = true } + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 800, width: 400, height: 300), + to: center + ) + + #expect(tracker.overlap(in: view) == 0) + } + + @Test func didChangeFrameSupersedesTheWillChangeTransition() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + let view = UIView(frame: window.bounds) + window.addSubview(view) + window.isHidden = false + defer { window.isHidden = true } + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 534, width: 400, height: 266), + to: center + ) + post( + UIResponder.keyboardDidChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + + // The settled frame wins, so layout catch-ups converge on final geometry. + #expect(abs(tracker.overlap(in: view) - 300) <= 0.5) + } + + @Test func didHideClearsTheTrackedTransition() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + #expect(tracker.latestTransition != nil) + + post(UIResponder.keyboardDidHideNotification, to: center) + #expect(tracker.latestTransition == nil) + } + + @Test func backgroundingClearsTheTrackedTransition() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + #expect(tracker.latestTransition != nil) + + post(UIApplication.didEnterBackgroundNotification, to: center) + #expect(tracker.latestTransition == nil) + } + + @Test func notificationWithoutAnEndFrameClearsTheTrackedTransition() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + post(UIResponder.keyboardWillChangeFrameNotification, to: center) + + // An unreadable follow-up means the keyboard state is unknown; fail + // closed so a stale floor cannot keep the dock raised. + #expect(tracker.latestTransition == nil) + } +} diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 618038032376..bd886bf6e692 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -353,12 +353,22 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let pointValue: (CGFloat) -> String = { String(format: "%.3f", Double($0)) } - let toolbarFrame = dockedToolbar?.frame - let toolbarMinY = toolbarFrame.map { pointValue($0.minY) } ?? "none" - let toolbarMaxY = toolbarFrame.map { pointValue($0.maxY) } ?? "none" + // The dock lives in the keyboard's own window now, so its frames are + // reported CONVERTED into surface coordinates (where the bars sit over + // the terminal). While the accessory is unhosted (no responder holds + // the keyboard) the frames are unknowable: -1 sentinel. + let composerFrameInSurface = dockFrameInSurfaceCoordinates(of: composerContainer) + let toolbarFrameInSurface = dockedToolbar.flatMap { dockFrameInSurfaceCoordinates(of: $0) } + let composerMinY = composerFrameInSurface.map { pointValue($0.minY) } ?? pointValue(-1) + let composerMaxY = composerFrameInSurface.map { pointValue($0.maxY) } ?? pointValue(-1) + let toolbarMinY = toolbarFrameInSurface.map { pointValue($0.minY) } ?? "none" + let toolbarMaxY = toolbarFrameInSurface.map { pointValue($0.maxY) } ?? "none" let keyboardTransitionID = bottomDockTransitionInFlight ? 1 : -1 let keyboardTransitionTarget = pointValue(keyboardHeight) - let keyboardGuideTop = pointValue(keyboardLayoutGuide.layoutFrame.minY) + // Key kept so the log format stays parseable: now the accessory's top + // edge in surface coordinates, -1 while the accessory is unhosted. + let keyboardGuideTop = accessoryFrameInSurfaceCoordinates() + .map { pointValue($0.minY) } ?? pointValue(-1) return [ "chromeHidden=\(chromeHidden ? 1 : 0)", "composerActive=\(composerActive ? 1 : 0)", @@ -370,8 +380,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { "inputScene=\(inputScene)", "inputModal=\(inputModal)", "keyboardHeight=\(pointValue(keyboardHeight))", - "composerMinY=\(pointValue(composerContainer.frame.minY))", - "composerMaxY=\(pointValue(composerContainer.frame.maxY))", + "composerMinY=\(composerMinY)", + "composerMaxY=\(composerMaxY)", "toolbarMinY=\(toolbarMinY)", "toolbarMaxY=\(toolbarMaxY)", "bottomSafeArea=\(pointValue(safeAreaInsetsBottom))", @@ -507,16 +517,25 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { lastAppliedContainerSize = .zero } private var viewportCoordinator = TerminalViewportCoordinator() - /// The toolbar/composer use Auto Layout; the Ghostty renderer does not. This bit - /// keeps its display-link viewport updates alive until the guide-constrained - /// toolbar's presentation frame reaches its model frame. + /// The dock accessory animates in the keyboard's own window; the Ghostty + /// renderer does not follow Auto Layout. This bit keeps the display-link + /// viewport updates alive until the accessory's presentation frame reaches + /// its model frame. private var bottomDockTransitionObserved = false - private var composerBottomToKeyboardConstraint: NSLayoutConstraint? - private var composerHeightConstraint: NSLayoutConstraint? - private var toolbarHeightConstraint: NSLayoutConstraint? + /// Process-wide keyboard-frame source for model catch-up on attach/layout. + /// Injected at construction (production callers take the shared tracker + /// default) so tests pass a notification-center-isolated instance without + /// touching the shared tracker other suites read. + private let keyboardFrameTracker: MobileKeyboardFrameTracker + /// The notification-tracked keyboard overlap, in WINDOW points (stable + /// across the surface's own frame changes). The viewport model converts it + /// to view space per layout pass. Zero while the keyboard is down or + /// unknown. The dock's POSITION no longer derives from this — the OS + /// keyboard system places the accessory — so it feeds only the grid + /// reservation. + private var keyboardOverlapFloorInWindow: CGFloat = 0 #if DEBUG private var keyboardHeightOverrideForTesting: CGFloat? - private var composerBottomForTestingConstraint: NSLayoutConstraint? #endif #if DEBUG @@ -690,7 +709,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // terminal tap focuses the proxy without closing the band), and the proxy // is a sibling of `composerContainer`, so `endEditing` on the container // alone would resign nothing and the keyboard would stay up. - if self.keyboardVisible { + // Decide from ACTUAL responder truth, not `keyboardVisible`: that + // bit is reconciled from keyboard notifications and lags a frame + // or two during rapid toggling, which made quick successive taps + // of the toggle re-focus when they should resign (and vice versa) + // until the keyboard wedged out of sync with the button. + if self.inputProxy.isFirstResponder || self.composerFieldIsFirstResponder { self.resignCurrentInput() } else { self.focusInput() @@ -721,6 +745,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { right: max(0, window.bounds.maxX - terminalFrame.maxX) ) } + inputProxy.keyboardAccessoryProvider = { [weak self] in + // The proxy rides the SAME dock accessory the surface returns, so + // the toolbar + composer band transfer seamlessly between the + // keyboard-up (proxy first responder) and keyboard-down (surface + // first responder) states. Withheld while the chrome is hidden. + guard let self, !self.chromeHidden else { return nil } + return self.keyboardDockAccessory + } return inputProxy }() @@ -732,11 +764,16 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// - terminalTheme: Renderer-effective colors used by surrounding UIKit chrome. /// - terminalConfigTheme: Raw Ghostty configuration defaults. Defaults to /// `terminalTheme` for callers that do not mirror a remote surface. + /// - keyboardFrameTracker: Keyboard-frame source for the bottom-dock + /// floor. Production callers keep the shared process-wide tracker; + /// tests inject a notification-center-isolated instance. public init(runtime: GhosttyRuntime, delegate: GhosttySurfaceViewDelegate, fontSize: Float32 = 10, terminalTheme: TerminalTheme = .monokai, - terminalConfigTheme: TerminalTheme? = nil) { + terminalConfigTheme: TerminalTheme? = nil, + keyboardFrameTracker: MobileKeyboardFrameTracker = .shared) { self.runtime = runtime self.delegate = delegate + self.keyboardFrameTracker = keyboardFrameTracker self.fontSize = fontSize self.liveFontSize = fontSize self.userBaseFontSize = fontSize @@ -765,10 +802,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { addSubview(debugAccessibilityProxy) addSubview(composerDockProbe) #endif - configureKeyboardLayoutGuide() - installPersistentToolbar() - installComposerContainer() - installBottomDockConstraints() + // Create the process-wide tracker no later than the first surface, so a + // keyboard transition that happens while THIS view is detached (workspace + // switch) is still observed and can seat the keyboard model on attach. + _ = MobileKeyboardFrameTracker.shared + installKeyboardDockAccessory() installArtifactChipContainer() initializeSurface() @@ -883,11 +921,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private var keyboardHeight: CGFloat = 0 private var keyboardVisible = false - /// Height the persistent bottom toolbar reserves in the terminal grid. The - /// toolbar is constrained to ``UIView/keyboardLayoutGuide`` and the viewport - /// coordinator consumes that same guide-derived overlap, so the grid must shrink - /// by this much to keep the bottom TUI rows visible above it. Zero until the - /// toolbar is installed (`installPersistentToolbar`). + /// Height the persistent toolbar row reserves in the terminal grid. The + /// row lives inside the OS-positioned dock accessory, so the grid must + /// shrink by this much to keep the bottom TUI rows visible above it. Zero + /// until the dock is installed (`installKeyboardDockAccessory`). private var reservedToolbarHeight: CGFloat = 0 /// Height of the docked accessory bar reserved in the grid geometry so the /// bottom TUI rows stay visible above it. Locked to the bar's actual button-row @@ -898,10 +935,26 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// toolbar's live top edge equal to the viewport edge; any whole-cell render /// remainder stays inside the terminal viewport instead of becoming toolbar fill. private static let persistentToolbarHeight: CGFloat = TerminalInputTextView.dockedButtonRowHeight - /// The docked accessory bar. Auto Layout pins it above the composer, whose bottom - /// is attached to ``UIView/keyboardLayoutGuide``. The viewport coordinator uses - /// the guide's same top edge for the terminal reservation. + /// The docked accessory bar (the toolbar row hosted inside + /// ``keyboardDockAccessory``). The viewport coordinator reserves its height + /// in the terminal grid; the OS keyboard system owns its position. private weak var dockedToolbar: UIView? + /// The system-positioned bottom dock: the toolbar row and the composer band + /// in one self-sizing `UIInputView`. Returned as the `inputAccessoryView` + /// of BOTH cmux keyboard owners — the terminal input proxy while typing + /// (the dock rides the keyboard's own animation) and this surface in the + /// keyboard-down state (the system docks it at the screen bottom) — so the + /// OS keyboard system owns the dock's POSITION in every state. + private var keyboardDockAccessory: KeyboardDockAccessoryView? + + /// The surface holds first responder whenever no cmux text responder owns + /// the keyboard, so the system keeps the dock accessory seated at the + /// screen bottom (the Messages pattern). + public override var canBecomeFirstResponder: Bool { true } + + public override var inputAccessoryView: UIView? { + chromeHidden ? nil : keyboardDockAccessory + } /// Whether the iMessage-style composer is currently open. The surface owns the /// whole bottom dock (terminal grid / toolbar / composer band / keyboard) in ONE /// coordinate system, so `composerActive` only drives the first-responder @@ -914,10 +967,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// The composer band: a surface-owned container the host installs the SwiftUI /// compose field into (via a `UIHostingController` in /// `GhosttySurfaceRepresentable`, which can see both layers; the terminal package - /// cannot import the UI package). Auto Layout pins it directly to - /// ``UIView/keyboardLayoutGuide`` (iMessage's field-nearest-keyboard layout), with - /// the docked toolbar riding its top edge and the terminal grid above that. The - /// viewport coordinator consumes the same guide overlap for the + /// cannot import the UI package). Hosted as the bottom slot of + /// ``keyboardDockAccessory`` (iMessage's field-nearest-keyboard layout), with + /// the toolbar row above it and the terminal grid above that. The viewport + /// coordinator reserves the same heights for the /// `terminal / toolbar / composer / keyboard` stack. private let composerContainer = UIView() /// Height (points) the open composer band reserves above the keyboard edge. Fed @@ -949,9 +1002,16 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// whichever registered surface happens to sort first. public var hostSurfaceID: String? - @objc private func handleKeyboardWillChangeFrame(_ notification: Notification) { + // Internal so tests can route a fixture notification through the + // production path for THIS view only (posting to the process-wide center + // would leak keyboard state into concurrently running suites). + @objc func handleKeyboardWillChangeFrame(_ notification: Notification) { guard let transition = MobileKeyboardTransition(notification: notification) else { return } + // Raised means more than the docked accessory: with the dock hosted as + // the inputAccessoryView, the keyboard-down state also posts frames + // covering just the accessory strip. let willBeVisible = transition.isVisible(in: self) + && trackedOverlapIsRaisedKeyboard(transition.overlapInWindow(of: self)) let wasVisible = keyboardVisible #if DEBUG // The composer-up/keyboard-down desync can be reached WITHOUT the dismiss @@ -978,20 +1038,43 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // and its text stays; tapping it refocuses and re-raises the keyboard. The // composer is dismissed only by its chevron or the toolbar composer button. // - // This notification owns responder-facing VISIBILITY only. UIKit's - // `keyboardLayoutGuide` owns all dock and viewport geometry, including safe-area - // fallback and interrupted keyboard motion. Keeping those responsibilities - // separate prevents a stale notification frame from becoming a second layout - // authority while preserving hardware/floating keyboard visibility semantics. + // This notification owns responder-facing VISIBILITY plus the keyboard + // overlap MODEL. The dock's POSITION is owned by the OS keyboard system + // (the dock is the responder's `inputAccessoryView` and rides in the + // keyboard's window), so nothing here moves bars; the grid reservation + // follows the tracker-derived overlap. updateDockedToolbarVisibility() + #if DEBUG + // Transition forensics for the bars-lag-the-keyboard class: one line per + // willChangeFrame with the notification frame, the tracker/floor pair, + // and the animation duration, so a dogfood recording can be lined up + // against which source moved (or failed to move) the grid and when. + MobileDebugLog.anchormux( + "kb.willChange endMinY=\(Int(transition.endFrame.minY)) endH=\(Int(transition.endFrame.height))" + + " dur=\(String(format: "%.2f", transition.duration))" + + " trackerOverlap=\(Int(keyboardFrameTracker.overlap(in: self)))" + + " floorW=\(Int(keyboardOverlapFloorInWindow)) visible=\(willBeVisible ? 1 : 0)" + ) + #endif + // The tracker (registered before any surface's handler) is the overlap + // model's ONLY data source; this handler merely re-reads it on the + // keyboard's own animation curve so the grid target updates in step + // with the keyboard. There are no dock constraints left to animate — + // the keyboard window carries the accessory — so the renderer follows + // through the display-link transition pass and the layout below. + transition.animate { [weak self] in + guard let self else { return } + self.synchronizeKeyboardFloorFromTracker() + } + layoutRenderedTerminalForCurrentViewport() setNeedsLayout() } - /// Keep the renderer clipped to UIKit's live keyboard guide during animation. + /// Keep the renderer tracking the live dock during keyboard animation. /// - /// The constrained toolbar/composer already follow the guide automatically. The - /// terminal renderer is not Auto Layout-backed, so its display-link pass reads the - /// constrained toolbar's presentation frame until it reaches the model target. + /// The dock accessory animates in the keyboard's own window. The terminal + /// renderer is not Auto Layout-backed, so its display-link pass reads the + /// accessory's presentation frame until it reaches the model target. private func advanceBottomDockTransition() { let isTransitioning = bottomDockTransitionInFlight guard isTransitioning || bottomDockTransitionObserved else { return } @@ -1003,44 +1086,112 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - /// Installs the system keyboard guide as the only production keyboard geometry source. - private func configureKeyboardLayoutGuide() { - keyboardLayoutGuide.followsUndockedKeyboard = false - keyboardLayoutGuide.usesBottomSafeArea = true + /// Re-derives the tracked keyboard overlap and applies it to the viewport + /// model. + /// + /// - Parameter overlap: The keyboard overlap to record, in WINDOW + /// coordinates (stable across the surface's own frame changes). + /// - Returns: Whether the overlap changed. + @discardableResult + private func applyKeyboardOverlapFloor(_ overlap: CGFloat) -> Bool { + #if DEBUG + guard keyboardHeightOverrideForTesting == nil else { return false } + #endif + let clamped = max(0, overlap) + guard abs(clamped - keyboardOverlapFloorInWindow) > 0.25 else { return false } + keyboardOverlapFloorInWindow = clamped + #if DEBUG + // Paired with kb.willChange: a kb.floor long after its kb.willChange is + // the late-snap signature (overlap applied by layout catch-up). + MobileDebugLog.anchormux("kb.floor \(Int(clamped))") + #endif + setNeedsGeometrySync() + return true + } + + /// The dock accessory's own footprint inside a tracked "keyboard" frame. + /// + /// UIKit's keyboard end frames INCLUDE the input accessory: with the dock + /// accessory-hosted, a raised keyboard's frame contains the dock's content + /// height, and the keyboard-DOWN docked state posts frames covering just + /// the accessory (content + home-indicator inset). Both the grid model and + /// the visibility bit must subtract this footprint or the dock double + /// counts and the docked state reads as a visible keyboard. + private var accessoryDockedFootprintInWindow: CGFloat { + guard let accessory = keyboardDockAccessory, !chromeHidden else { return 0 } + return accessory.contentHeight + (window?.safeAreaInsets.bottom ?? 0) + } + + /// Whether the tracked overlap represents a genuinely raised keyboard, as + /// opposed to the docked accessory alone. + private func trackedOverlapIsRaisedKeyboard(_ overlapInWindow: CGFloat) -> Bool { + overlapInWindow > accessoryDockedFootprintInWindow + 8 + } + + /// The KEYBOARD-ONLY portion of the tracked overlap converted into this + /// view's coordinates against its CURRENT frame, for the viewport model. + /// Re-derived per layout pass so it converges with the surface's settled + /// frame even when that frame animated during the transition. The + /// accessory's own height is subtracted (the grid reserves the dock + /// separately via `reservedToolbarHeight` + `composerBandHeight`). + private var keyboardOverlapFloorInBounds: CGFloat { + guard keyboardOverlapFloorInWindow > 0, let window else { return 0 } + guard trackedOverlapIsRaisedKeyboard(keyboardOverlapFloorInWindow) else { return 0 } + let keyboardOnly = max( + 0, keyboardOverlapFloorInWindow - (keyboardDockAccessory?.contentHeight ?? 0) + ) + let viewMaxYInWindow = convert(bounds, to: window).maxY + let belowView = max(0, window.bounds.maxY - viewMaxYInWindow) + return max(0, keyboardOnly - belowView) } - /// Pins the whole dock stack to Apple's keyboard guide. - private func installBottomDockConstraints() { - guard let dockedToolbar else { return } - dockedToolbar.translatesAutoresizingMaskIntoConstraints = false - composerContainer.translatesAutoresizingMaskIntoConstraints = false + /// Catches the keyboard overlap model up from the process-wide tracker. + /// + /// A view attached to a window AFTER the keyboard came up never receives + /// the keyboard notification for that transition. The tracker observed the + /// transition process-wide, so every layout pass can re-derive the overlap + /// for the view's current window. + /// + /// - Returns: Whether the overlap changed. + @discardableResult + private func synchronizeKeyboardFloorFromTracker() -> Bool { + guard window != nil else { return false } + synchronizeKeyboardVisibilityFromTracker() + return applyKeyboardOverlapFloor(keyboardFrameTracker.overlapInWindow(of: self)) + } - let composerBottom = composerContainer.bottomAnchor.constraint( - equalTo: keyboardLayoutGuide.topAnchor + /// Reconciles the responder-facing visibility bit — and with it the + /// toolbar's keyboard-toggle glyph — with the tracked keyboard state. + /// + /// A surface (re)mounted after the keyboard changed never receives the + /// notification that flipped it, so leaving a workspace with the keyboard + /// up and re-entering it otherwise shows the stale hide-keyboard glyph + /// (and the toggle would resign a keyboard that is not there) until the + /// next real transition. Change-guarded so the glyph cross-dissolve only + /// runs on actual flips, not every layout pass. + private func synchronizeKeyboardVisibilityFromTracker() { + // Raised means more than the docked accessory: the accessory alone + // (keyboard down, surface first responder) also posts keyboard frames. + let visible = trackedOverlapIsRaisedKeyboard( + keyboardFrameTracker.overlapInWindow(of: self) ) - let composerHeight = composerContainer.heightAnchor.constraint(equalToConstant: 0) - let toolbarHeight = dockedToolbar.heightAnchor.constraint(equalToConstant: 0) - composerBottomToKeyboardConstraint = composerBottom - composerHeightConstraint = composerHeight - self.toolbarHeightConstraint = toolbarHeight - - NSLayoutConstraint.activate([ - composerContainer.leadingAnchor.constraint(equalTo: leadingAnchor), - composerContainer.trailingAnchor.constraint(equalTo: trailingAnchor), - composerBottom, - composerHeight, - dockedToolbar.leadingAnchor.constraint(equalTo: leadingAnchor), - dockedToolbar.trailingAnchor.constraint(equalTo: trailingAnchor), - dockedToolbar.bottomAnchor.constraint(equalTo: composerContainer.topAnchor), - toolbarHeight, - ]) - layoutBottomDock() + guard visible != keyboardVisible else { return } + keyboardVisible = visible + inputProxy.setKeyboardShown(visible) } - /// Updates the renderer's overlap model from the guide's target top edge. + /// Updates the renderer's overlap model from the notification-tracked, + /// window-anchored keyboard overlap, converted into this view's bounds per + /// pass so it converges with the surface's settled frame. @discardableResult - private func synchronizeKeyboardGeometryFromLayoutGuide() -> Bool { - let nextHeight = keyboardOverlapFromLayoutGuide + private func synchronizeKeyboardGeometry() -> Bool { + #if DEBUG + // The synthetic override writes `keyboardHeight` directly; the tracked + // overlap (forced to zero under the override) must not stomp it. + guard keyboardHeightOverrideForTesting == nil else { return false } + #endif + synchronizeKeyboardFloorFromTracker() + let nextHeight = keyboardOverlapFloorInBounds guard abs(nextHeight - keyboardHeight) > 0.25 else { return false } keyboardHeight = nextHeight bottomDockTransitionObserved = bottomDockTransitionInFlight @@ -1048,58 +1199,72 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { return true } - /// Keyboard overlap represented by the system guide, excluding its safe-area fallback. - private var keyboardOverlapFromLayoutGuide: CGFloat { - #if DEBUG - if let keyboardHeightOverrideForTesting { - return max(0, keyboardHeightOverrideForTesting) - } - #endif - guard window != nil, bounds.height > 0 else { return 0 } - let guideFrame = keyboardLayoutGuide.layoutFrame - // A guide frame is usable only after UIKit has seated it against this view's - // bottom edge. During first attachment or rotation, keep the prior overlap for - // that transient pass instead of interpreting CGRect.zero as a full-screen - // keyboard. - guard abs(guideFrame.maxY - bounds.maxY) <= 1 else { return keyboardHeight } - let guideTop = min(max(0, guideFrame.minY), bounds.maxY) - let occupancy = max(0, bounds.maxY - guideTop) - return occupancy > safeAreaInsetsBottom + 0.5 ? occupancy : 0 - } - - /// Whether UIKit is still animating the guide-constrained dock toward its target. + /// Whether the OS is still animating the dock accessory toward its target. + /// + /// The accessory animates in the keyboard's own window, so its presentation + /// frame is compared against its model frame after converting both into + /// this view's coordinates. False when the accessory (or either window) is + /// unavailable, or when no presentation animation is in flight. private var bottomDockTransitionInFlight: Bool { #if DEBUG if keyboardHeightOverrideForTesting != nil { return false } #endif guard dockedToolbarShouldBeVisible, - let dockedToolbar, - !dockedToolbar.isHidden, - let presentationFrame = dockedToolbar.layer.presentation()?.frame else { + let model = accessoryFrameInSurfaceCoordinates(), + let presented = accessoryPresentationFrameInSurfaceCoordinates() else { return false } - return abs(presentationFrame.minY - dockedToolbar.frame.minY) > 0.5 + return abs(presented.minY - model.minY) > 0.5 + } + + /// Converts a dock rect (in `superview`'s coordinates, inside the keyboard + /// window) into this view's coordinates, routing explicitly through both + /// windows because the accessory is not in this view's hierarchy. + private func convertDockRect(_ rect: CGRect, from superview: UIView) -> CGRect? { + guard let accessoryWindow = keyboardDockAccessory?.window, let window else { return nil } + let inAccessoryWindow = superview.convert(rect, to: accessoryWindow) + let inOwnWindow = accessoryWindow.convert(inAccessoryWindow, to: window) + return convert(inOwnWindow, from: window) + } + + /// The accessory's model frame in this view's coordinates, or nil while the + /// accessory (or either window) is unavailable. + private func accessoryFrameInSurfaceCoordinates() -> CGRect? { + guard let accessory = keyboardDockAccessory, + let superview = accessory.superview else { return nil } + return convertDockRect(accessory.frame, from: superview) + } + + /// The accessory's live presentation frame in this view's coordinates, or + /// nil while it is unavailable or no animation has produced a presentation + /// layer. + private func accessoryPresentationFrameInSurfaceCoordinates() -> CGRect? { + guard let accessory = keyboardDockAccessory, + let superview = accessory.superview, + let presentationFrame = accessory.layer.presentation()?.frame else { return nil } + return convertDockRect(presentationFrame, from: superview) + } + + /// A dock content view's frame (toolbar row or composer band, hosted inside + /// the accessory) converted into this view's coordinates, or nil while the + /// accessory is unhosted. + private func dockFrameInSurfaceCoordinates(of view: UIView) -> CGRect? { + guard let superview = view.superview else { return nil } + return convertDockRect(view.frame, from: superview) } #if DEBUG - /// Switches the dock to a synthetic bottom anchor for host tests and previews. + /// Forces a synthetic keyboard overlap for host tests and previews. Only + /// the model/grid is synthetic now — the dock accessory needs no bottom + /// anchor because the OS positions it. private func setKeyboardHeightOverrideForTesting(_ height: CGFloat) { let clamped = max(0, height) keyboardHeightOverrideForTesting = clamped keyboardHeight = clamped bottomDockTransitionObserved = false - - composerBottomToKeyboardConstraint?.isActive = false - if composerBottomForTestingConstraint == nil { - composerBottomForTestingConstraint = composerContainer.bottomAnchor.constraint( - equalTo: bottomAnchor - ) - } - composerBottomForTestingConstraint?.constant = -TerminalLetterboxGeometry.keyboardOccupancy( - keyboardHeight: clamped, - bottomSafeAreaInset: safeAreaInsetsBottom - ) - composerBottomForTestingConstraint?.isActive = true + // A live tracked overlap would fight the override on the next + // geometry pass; zero it while the override is authoritative. + keyboardOverlapFloorInWindow = 0 } #endif @@ -1130,31 +1295,32 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { showZoomOverlay() zoomOverlayLastInteraction = CACurrentMediaTime() + 3600 } + #endif - /// Dock the accessory bar as a persistent bottom toolbar. Auto Layout pins it - /// through the composer container to ``UIView/keyboardLayoutGuide``; the viewport - /// coordinator consumes the same guide-derived overlap for the terminal grid. - private func installPersistentToolbar() { + /// Builds the keyboard dock accessory around the accessory toolbar row and + /// the composer band. The dock is NOT a subview of this surface: it lives + /// in the keyboard's own window whenever a cmux responder (the input + /// proxy, the composer field, or this surface itself) is first responder, + /// so the OS keyboard system positions it in every state and no zPosition + /// juggling against the Ghostty render layer is needed. Neither content + /// view clips its bounds: the dock's Liquid-Glass controls lift past the + /// band edge on drag. + private func installKeyboardDockAccessory() { let toolbar = inputProxy.toolbarView - addSubview(toolbar) dockedToolbar = toolbar - // Raise the toolbar above the Ghostty renderer's own sublayer (which it - // inserts directly into `self.layer`), so a dragged/lifted Liquid-Glass button - // floating UP over the terminal is not occluded or clipped by the render layer - // (item 6). Subview order alone does not guarantee this because the renderer - // sublayer is composited at the layer level; the zoom overlay uses the same - // `zPosition` lever. The toolbar must also not clip its own bounds so the lift - // is visible above the strip. - toolbar.layer.zPosition = Self.bottomChromeZPosition toolbar.clipsToBounds = false + composerContainer.backgroundColor = .clear + composerContainer.isHidden = true + composerContainer.clipsToBounds = false + keyboardDockAccessory = KeyboardDockAccessoryView( + toolbar: toolbar, + composer: composerContainer, + toolbarRowHeight: Self.persistentToolbarHeight + ) updateDockedToolbarVisibility() } - /// Layer `zPosition` for the bottom chrome (toolbar + composer band), placing it - /// above the Ghostty renderer's sublayer so a lifted Liquid-Glass button is not - /// clipped by the terminal render bounds (item 6). Below the zoom HUD (1100). - private static let bottomChromeZPosition: CGFloat = 1000 /// Floats above dock chrome, terminal content, AND the verified-replay /// frozen presentation (zPosition 2000). The freeze copies only renderer /// pixels, so anything below it blinks out for the length of every @@ -1227,8 +1393,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { bottomSafeAreaInset: safeAreaInsetsBottom, chromeHidden: chromeHidden, chromeVisible: dockedToolbarShouldBeVisible && dockedToolbar?.isHidden == false, - toolbarFrame: dockedToolbar?.frame, - toolbarPresentationFrame: dockedToolbar?.layer.presentation()?.frame, + // The dock lives in the keyboard's window; hand the coordinator its + // frame converted into SURFACE coordinates (model and live + // presentation respectively), nil while the accessory is unhosted. + toolbarFrame: accessoryFrameInSurfaceCoordinates(), + toolbarPresentationFrame: accessoryPresentationFrameInSurfaceCoordinates(), viewportNegotiationUnsettled: bottomDockTransitionInFlight || pendingViewportReport != nil || awaitingViewportEcho @@ -1307,10 +1476,15 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { dockedToolbar?.isHidden = !shouldShow // The composer band rides with the toolbar: hide it when the chrome is // suppressed, show it again when the chrome returns and a field is mounted. - // Its height constraint already collapses to zero while hidden; toggling - // `isHidden` also stops it intercepting taps. + // Both flags hide CONTENT inside the accessory; toggling `isHidden` also + // stops the band intercepting taps. composerContainer.isHidden = !shouldShow || composerContainer.subviews.isEmpty reservedToolbarHeight = reserved + // A chrome toggle changes what `inputAccessoryView` returns, and the + // system re-reads it only on a reload. Reload both candidate first + // responders — cheap and safe when either is not first responder. + reloadInputViews() + inputProxy.reloadInputViews() layoutRenderedTerminalForCurrentViewport() updateArtifactChipVisibility(animated: true) setNeedsGeometrySync() @@ -1320,38 +1494,42 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// Temporarily hide (or re-show) the bottom chrome — the always-visible toolbar /// and any open composer band — via the HIDE button (item 2). /// - /// Hiding also drops the software keyboard: with the toolbar always visible, HIDE - /// only makes sense as "clear all chrome to see the full terminal", which requires - /// resigning the keyboard too. `isComposerPresented` is left untouched, so the - /// composer (and its draft) reappear intact on the next terminal tap - /// (``handleTap``). UIKit animates keyboard movement through its layout guide; - /// ``animateBottomDock`` handles only the chrome-height change. - private func setChromeHidden(_ hidden: Bool) { + /// Hiding also drops the software keyboard AND the surface's own + /// first-responder seat: with the dock hosted by the keyboard system, HIDE + /// means "no responder offers the accessory", so the typing responder is + /// resigned, the surface resigns, and the input views reload with a nil + /// accessory. `isComposerPresented` is left untouched, so the composer (and + /// its draft) reappear intact on the next terminal tap (``handleTap``), + /// which un-hides and re-seats the surface as first responder. + /// Internal (not private) as the chrome test seam. + func setChromeHidden(_ hidden: Bool) { guard chromeHidden != hidden else { return } chromeHidden = hidden - if hidden, keyboardVisible { - // Drop the keyboard first; its layout guide re-seats the dock while the - // visibility update below removes the toolbar/composer. Resign - // whichever responder actually owns the keyboard — the band can be - // presented while the terminal's hidden input proxy (a sibling of - // `composerContainer`) holds first responder, so gating on - // `composerActive` alone would leave the keyboard up while the chrome - // hides. - resignCurrentInput() + if hidden { + if keyboardVisible { + // Drop the keyboard first. Resign whichever responder actually + // owns it — the band can be presented while the terminal's + // hidden input proxy holds first responder, so gating on + // `composerActive` alone would leave the keyboard up while the + // chrome hides. + resignCurrentInput() + } + // Give up the keyboard-down seat too, so no responder offers the + // dock accessory while the chrome is hidden. + resignFirstResponder() + reloadInputViews() } updateDockedToolbarVisibility() if hidden { - // Hide: animate the dock collapsing down into the bottom edge. (The toolbar - // is set `isHidden` only after this animation by `updateDockedToolbarVisibility` - // — actually `isHidden` is set synchronously, so this animate-out is largely - // invisible, but it keeps the frame coherent for the next show.) + // Hide: animate the grid reclaiming the dock's reservation. animateBottomDock() } else { - // Show: snap real frames into place with the bar visible, then let the - // ``handleTap``-driven `focusInput()` → keyboard-show animation carry the - // motion. Animating here from the collapsed bottom-edge strip would - // double-animate against the keyboard rise. + // Show: snap real frames into place with the bar visible, re-take + // the keyboard-down seat so the system docks the accessory, then + // let any ``handleTap``-driven `focusInput()` → keyboard-show + // animation carry further motion. layoutBottomDock() + becomeFirstResponder() } setNeedsGeometrySync() } @@ -1508,24 +1686,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - /// Install the composer band container into the surface's view hierarchy, above - /// the docked toolbar. Hidden and zero-height until the host mounts a compose - /// field into it (``mountComposerView(_:)``); the surface positions it in - /// ``layoutBottomDock()`` and reserves its height in the grid. Auto Layout pins its - /// bottom to ``UIView/keyboardLayoutGuide`` and pins the toolbar above it, so UIKit - /// and the viewport coordinator share one keyboard edge. - private func installComposerContainer() { - composerContainer.backgroundColor = .clear - composerContainer.isHidden = true - // Do NOT clip: the composer's Liquid-Glass controls lift/shadow past the band - // edge, and the band must sit above the Ghostty render layer (item 6) so the - // glass is not clipped by the terminal bounds. Raised to the same chrome - // z-position as the toolbar. - composerContainer.clipsToBounds = false - composerContainer.layer.zPosition = Self.bottomChromeZPosition - addSubview(composerContainer) - } - /// Mounts the host-built artifact chip inside the terminal's bottom-dock /// coordinate system, or hides it when `view` is `nil`. /// @@ -1671,8 +1831,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// reports its measured height via ``setComposerBandHeight(_:animated:)``. /// /// The mounted view is pinned edge-to-edge inside the band with Auto Layout, so it - /// fills the guide-pinned band — there is no second keyboard layout system fighting - /// the surface for the band's frame. + /// fills the accessory-hosted band — there is no second keyboard layout system + /// fighting the OS-positioned dock for the band's frame. public func mountComposerView(_ view: UIView?) { composerContainer.subviews.forEach { $0.removeFromSuperview() } guard let view else { @@ -1696,8 +1856,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// toolbar, from the hosted compose field's intrinsic content size. Drives the /// grid reservation (so a field-grow pushes only the terminal up) and the dock /// layout. When `animated`, the reservation + reflow run inside a `UIView.animate`; - /// keyboard movement itself remains owned by ``UIView/keyboardLayoutGuide``. - /// Idempotent: a no-op when the height is + /// keyboard movement itself remains owned by the OS keyboard system, which + /// carries the dock accessory. Idempotent: a no-op when the height is /// unchanged (then `completion` runs immediately so an unmount-on-close never /// strands the mounted field). /// @@ -1721,7 +1881,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let apply = { [weak self] in guard let self else { return } self.layoutRenderedTerminalForCurrentViewport() + // Forward the band height into the accessory INSIDE the animation + // closure so the accessory's constraint change (and its self-sizing) + // animates with the reflow. self.layoutBottomDock() + self.keyboardDockAccessory?.layoutIfNeeded() self.layoutIfNeeded() } if animated { @@ -1741,12 +1905,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { layoutBottomDock(using: viewportSnapshot()) } + /// Positions nothing: the OS keyboard system owns the dock's placement. + /// Only the composer band HEIGHT is forwarded into the accessory (which + /// self-sizes around it), plus the surface-owned artifact chip layout. private func layoutBottomDock(using snapshot: TerminalViewportSnapshot) { - composerHeightConstraint?.constant = snapshot.composerFrame.height - toolbarHeightConstraint?.constant = min( - reservedToolbarHeight, - snapshot.toolbarFrame.height - ) + keyboardDockAccessory?.setComposerBandHeight(snapshot.composerFrame.height) layoutArtifactChip(using: snapshot) } @@ -2159,7 +2322,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { public override func layoutSubviews() { super.layoutSubviews() - synchronizeKeyboardGeometryFromLayoutGuide() + synchronizeKeyboardGeometry() let snapshot = viewportSnapshot() layoutBottomDock(using: snapshot) layoutRenderedTerminalForCurrentViewport(using: snapshot) @@ -2194,7 +2357,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { setKeyboardHeightOverrideForTesting(keyboardHeightOverrideForTesting) } #endif - synchronizeKeyboardGeometryFromLayoutGuide() + synchronizeKeyboardGeometry() let snapshot = viewportSnapshot() layoutBottomDock(using: snapshot) layoutRenderedTerminalForCurrentViewport(using: snapshot) @@ -2220,6 +2383,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { setFocus(true) if autoFocusOnWindowAttach, UIApplication.shared.applicationState == .active { focusInput() + } else if !chromeHidden { + // Keyboard-down docking: no cmux text responder owns the + // keyboard, so the SURFACE takes first responder and the system + // seats the dock accessory at the screen bottom. + becomeFirstResponder() } resetVisibleArtifactCountTracking() startDisplayLink() @@ -2663,13 +2831,21 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { public func resignCurrentInput() { synchronizeActualInputOwner() inputSession.send(.releaseFocus) + // Keyboard-down docking: with the typing responder released, the + // SURFACE takes first responder (while attached and the chrome is + // visible) so the dock accessory stays seated at the screen bottom + // instead of leaving with the keyboard. + if window != nil, !chromeHidden { + becomeFirstResponder() + } } /// Resigns this surface's hidden text input. public func resignInput() { resignCurrentInput() - // Geometry follows `keyboardLayoutGuide`; responder release only decides - // whether UIKit should dismiss the software keyboard. + // The dock accessory is OS-positioned; responder release only decides + // whether UIKit dismisses the software keyboard (the surface re-takes + // first responder above so the dock docks instead of disappearing). } /// Stops user-visible and accessibility output from a surface SwiftUI has removed. @@ -2711,6 +2887,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { renderInFlightSince = nil needsAnotherRender = false inputSession.send(.surfaceDetached) + // Drop the keyboard-down docking seat; UIKit reclaims the accessory's + // input view on resign, so the dock needs no manual removal. + resignFirstResponder() bottomDockTransitionObserved = false stopDisplayLink() setFocus(false) @@ -2732,6 +2911,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { runtime?.tick() } + #if DEBUG + /// Test seam: the hidden typing responder, so tests can assert it vends + /// the same dock accessory the surface does. + var inputProxyForTesting: TerminalInputTextView { inputProxy } + #endif + func simulateInputProxyTextChangeForTesting(_ text: String, isComposing: Bool) { setFocus(true) inputProxy.simulateTextChangeForTesting(text, isComposing: isComposing) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/KeyboardDockAccessoryView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/KeyboardDockAccessoryView.swift new file mode 100644 index 000000000000..f62dd6b7f350 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/KeyboardDockAccessoryView.swift @@ -0,0 +1,83 @@ +#if canImport(UIKit) +import UIKit + +/// The bottom dock (accessory toolbar row + composer band) as a self-sizing +/// keyboard accessory. +/// +/// The system positions this view: while a text responder owns the keyboard it +/// rides the keyboard's own animation (rises, dismissals, and interactive +/// gestures included), and while the surface itself is first responder — the +/// keyboard-down state — the system docks it at the screen bottom. Ownership by +/// the keyboard window replaces the previous constraint stack (layout-guide +/// equality, notification floor, safe-area cap), whose inputs proved unreliable +/// on device: the layout guide froze or lagged transitions and the surface's +/// own frame breathes by the home-indicator height mid-transition, so any +/// self-computed position was wrong at some point of every animation. +/// +/// Self-sizing: `allowsSelfSizing` with internal constraints. The content pins +/// to the safe-area bottom, so the docked state automatically clears the home +/// indicator and the keyboard-attached state sits flush on the keyboard. +final class KeyboardDockAccessoryView: UIInputView { + private let toolbarSlot: UIView + private let composerSlot: UIView + private let toolbarHeight: NSLayoutConstraint + private let composerHeight: NSLayoutConstraint + + /// Creates the dock accessory around the surface-owned toolbar and + /// composer container views. + /// + /// - Parameters: + /// - toolbar: The accessory toolbar row (modifiers, Tab/Esc, toggle). + /// - composer: The composer band container the host mounts SwiftUI into. + /// - toolbarRowHeight: The fixed toolbar strip height. + init(toolbar: UIView, composer: UIView, toolbarRowHeight: CGFloat) { + toolbarSlot = toolbar + composerSlot = composer + toolbarHeight = toolbar.heightAnchor.constraint(equalToConstant: toolbarRowHeight) + composerHeight = composer.heightAnchor.constraint(equalToConstant: 0) + super.init(frame: .zero, inputViewStyle: .keyboard) + allowsSelfSizing = true + translatesAutoresizingMaskIntoConstraints = false + // The dock's Liquid-Glass controls lift past the band edge on drag. + clipsToBounds = false + + toolbar.translatesAutoresizingMaskIntoConstraints = false + composer.translatesAutoresizingMaskIntoConstraints = false + addSubview(toolbar) + addSubview(composer) + NSLayoutConstraint.activate([ + toolbar.topAnchor.constraint(equalTo: topAnchor), + toolbar.leadingAnchor.constraint(equalTo: leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: trailingAnchor), + toolbarHeight, + composer.topAnchor.constraint(equalTo: toolbar.bottomAnchor), + composer.leadingAnchor.constraint(equalTo: leadingAnchor), + composer.trailingAnchor.constraint(equalTo: trailingAnchor), + composerHeight, + // Safe-area pin: docked = clears the home indicator; on the + // keyboard = flush (the keyboard region has no bottom inset). + composer.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + /// The dock's content height above the accessory's safe-area inset. + var contentHeight: CGFloat { + toolbarHeight.constant + composerHeight.constant + } + + /// Resizes the composer band (0 collapses it while the composer is closed). + /// + /// - Parameter height: The band height in points. + /// - Returns: Whether the height changed. + @discardableResult + func setComposerBandHeight(_ height: CGFloat) -> Bool { + let clamped = max(0, height) + guard abs(composerHeight.constant - clamped) > 0.25 else { return false } + composerHeight.constant = clamped + return true + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index 50678f5095b7..d3d9e49dea38 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -128,6 +128,13 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { override var canBecomeFirstResponder: Bool { true } + /// Supplies the shared keyboard dock accessory (toolbar row + composer + /// band). Set by `GhosttySurfaceView`; resolved on every keyboard + /// presentation so a chrome toggle can withhold it. + var keyboardAccessoryProvider: (() -> UIView?)? + + override var inputAccessoryView: UIView? { keyboardAccessoryProvider?() } + override func becomeFirstResponder() -> Bool { let wasFirstResponder = isFirstResponder let succeeded = super.becomeFirstResponder() @@ -351,11 +358,16 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // Pinned keyboard dismiss button on the left let dismissButton = UIButton(type: .system) - dismissButton.setImage(UIImage(systemName: "keyboard.chevron.compact.down", withConfiguration: Self.accessoryButtonSymbolConfig), for: .normal) + // Born in the keyboard-DOWN state: a freshly (re)mounted surface has no + // keyboard until something focuses, and `setKeyboardShown(_:)` flips the + // glyph on real transitions. Constructing with the chevron-down glyph + // showed a stale "hide keyboard" toggle whenever a workspace was + // re-entered with the keyboard dismissed. + dismissButton.setImage(UIImage(systemName: "keyboard", withConfiguration: Self.accessoryButtonSymbolConfig), for: .normal) dismissButton.tintColor = themeChromeColor.withAlphaComponent(0.78) dismissButton.addTarget(self, action: #selector(handleHideKeyboard), for: .touchUpInside) dismissButton.accessibilityIdentifier = "terminal.inputAccessory.hideKeyboard" - dismissButton.accessibilityLabel = String(localized: "terminal.input_accessory.hideKeyboard", defaultValue: "Hide Keyboard") + dismissButton.accessibilityLabel = String(localized: "terminal.input_accessory.showKeyboard", defaultValue: "Show Keyboard") dismissButton.translatesAutoresizingMaskIntoConstraints = false self.dismissButton = dismissButton @@ -421,9 +433,9 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { ) // A short fixed-height strip pinned to the container's BOTTOM (minus - // ``dockedBottomPadding``) that holds the button row. The host pins that - // bottom edge through the composer to `keyboardLayoutGuide.topAnchor`, so - // bottom-pinning the controls keeps them glued to the system keyboard edge. + // ``dockedBottomPadding``) that holds the button row. The row is the top + // slot of the shared keyboard dock accessory, so bottom-pinning the + // controls keeps them glued to the composer band / keyboard edge below. // `dockedBottomPadding` lifts the strip off the very bottom edge so the // controls have breathing room. let buttonRow = UILayoutGuide() @@ -499,11 +511,13 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// The terminal accessory bar (modifier keys, arrow nub, shortcut buttons). /// - /// Formerly the keyboard `inputAccessoryView`; it is now docked as a - /// persistent bottom bar by ``GhosttySurfaceView`` so it stays visible when - /// the keyboard is dismissed and reserves space above the bottom TUI rows. - /// Its buttons still target this text view, so the action wiring is intact - /// regardless of where the view is hosted. + /// Once again keyboard-accessory-hosted: it is the top row of the shared + /// `KeyboardDockAccessoryView` that ``GhosttySurfaceView`` returns as the + /// `inputAccessoryView` of every cmux keyboard owner, so the system + /// positions it (riding the keyboard while typing, docked at the screen + /// bottom while the surface itself holds first responder). Its buttons + /// still target this text view, so the action wiring is intact regardless + /// of where the view is hosted. var toolbarView: UIView { terminalAccessoryToolbar } private weak var accessoryStackView: UIStackView? @@ -693,12 +707,12 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { backgroundColor = .clear tintColor = .clear // The view owns no visible content; it is a zero-size hidden responder - // docked by `GhosttySurfaceView`. The accessory bar is no longer the - // keyboard's `inputAccessoryView`; `GhosttySurfaceView` docks - // `toolbarView` persistently at the bottom so it survives keyboard - // dismissal. Leaving `inputAccessoryView` nil means the keyboard shows - // without its own accessory (the docked bar rides above it via - // `keyboardLayoutGuide`). + // owned by `GhosttySurfaceView`. The accessory bar is once again + // keyboard-accessory-hosted: `keyboardAccessoryProvider` returns the + // surface's shared `KeyboardDockAccessoryView` (toolbar row + composer + // band), so the system keyboard carries the whole dock on its own + // animation and docks it at the screen bottom when the surface holds + // first responder instead. NotificationCenter.default.addObserver( self, selector: #selector(handleAccessoryConfigurationChanged), diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift new file mode 100644 index 000000000000..93c7b95db5fc --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift @@ -0,0 +1,262 @@ +#if canImport(UIKit) && DEBUG +import CMUXMobileCore +import CmuxMobileSupport +import Testing +import UIKit + +@testable import CmuxMobileTerminal + +/// Contract coverage for the keyboard dock accessory: the bottom dock (toolbar +/// row + composer band) is one self-sizing `UIInputView` that the OS keyboard +/// system positions. Every cmux keyboard owner returns it as its +/// `inputAccessoryView` — the terminal input proxy while typing (the dock rides +/// the keyboard's own animation) and the surface itself in the keyboard-down +/// state (the system docks it at the screen bottom) — so the surface never +/// computes a dock position. What remains surface-owned is the MODEL: the grid +/// reservation derived from the notification-tracked keyboard overlap. +/// +/// A test window never shows a real keyboard, so these tests assert the +/// responder wiring and the model, not OS-owned positions. Each test injects a +/// notification-center-isolated `MobileKeyboardFrameTracker` (the model's +/// single data source) through the surface initializer and calls the view's +/// notification handler directly, mirroring the production wiring where the +/// shared tracker observes the same notifications the view does. +@MainActor +@Suite("Keyboard dock accessory", .serialized) +struct GhosttySurfaceKeyboardDockFloorTests { + private final class Delegate: NSObject, GhosttySurfaceViewDelegate { + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didProduceInput data: Data + ) {} + + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didResize size: TerminalGridSize, + reportID: UInt64 + ) {} + } + + private struct Harness { + let view: GhosttySurfaceView + let window: UIWindow + let center: NotificationCenter + let tracker: MobileKeyboardFrameTracker + /// Retained here because the surface only holds it weakly. + let delegate: Delegate + } + + private static let windowHeight: CGFloat = 874 + private static let keyboardHeight: CGFloat = 336 + + private func makeHarness(attached: Bool = true) throws -> Harness { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + let delegate = Delegate() + let view = GhosttySurfaceView( + runtime: try GhosttyRuntime.shared(), + delegate: delegate, + fontSize: 10, + keyboardFrameTracker: tracker + ) + view.autoFocusOnWindowAttach = false + view.isRenderDispatchSuppressed = true + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: Self.windowHeight)) + if attached { + attach(view, to: window) + } + return Harness(view: view, window: window, center: center, tracker: tracker, delegate: delegate) + } + + private func attach(_ view: GhosttySurfaceView, to window: UIWindow) { + view.frame = window.bounds + window.addSubview(view) + // Key AND visible: first-responder status (the keyboard-down docking + // seat under test) requires the view's window to be key. + window.makeKeyAndVisible() + view.setNeedsLayout() + view.layoutIfNeeded() + } + + private func tearDown(_ harness: Harness) { + harness.view.prepareForDismantle() + harness.view.removeFromSuperview() + harness.window.isHidden = true + } + + /// The keyboard notification shape UIKit posts, with `duration` omitted so + /// the transition applies synchronously in tests. + private func keyboardNotification(coveringBottom overlap: CGFloat) -> Notification { + Notification( + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + userInfo: [ + UIResponder.keyboardFrameEndUserInfoKey: CGRect( + x: 0, + y: Self.windowHeight - overlap, + width: 402, + height: Self.keyboardHeight + ), + ] + ) + } + + /// Delivers a keyboard transition the way production sees it: the tracker + /// (registered first) records it, then the view's handler re-applies the + /// overlap model on the notification's animation curve. + private func deliverKeyboardTransition( + coveringBottom overlap: CGFloat, + to harness: Harness + ) { + let notification = keyboardNotification(coveringBottom: overlap) + harness.center.post(notification) + harness.view.handleKeyboardWillChangeFrame(notification) + harness.view.setNeedsLayout() + harness.view.layoutIfNeeded() + } + + private func probeValue(of view: GhosttySurfaceView, key: String) -> CGFloat? { + let entry = view.composerDockProbeValue + .split(separator: ";") + .first { $0.hasPrefix("\(key)=") } + guard let entry, let value = Double(entry.dropFirst(key.count + 1)) else { + return nil + } + return CGFloat(value) + } + + /// The accessory toolbar's keyboard-toggle button, found by its stable + /// accessibility identifier in the given subtree. + private func keyboardToggleButton(in view: UIView) -> UIButton? { + if let button = view as? UIButton, + button.accessibilityIdentifier == "terminal.inputAccessory.hideKeyboard" { + return button + } + for subview in view.subviews { + if let found = keyboardToggleButton(in: subview) { + return found + } + } + return nil + } + + @Test("the dock accessory exists, hosts toolbar + composer, and is the inputAccessoryView") + func accessoryHostsToolbarAndComposerBand() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + let accessory = try #require( + harness.view.inputAccessoryView as? KeyboardDockAccessoryView + ) + // The toolbar row (identified by its keyboard-toggle button) lives + // INSIDE the accessory, not in the surface's own subtree. + #expect(keyboardToggleButton(in: accessory) != nil) + #expect(keyboardToggleButton(in: harness.view) == nil) + // The composer band is the accessory's other slot: a host-mounted + // compose view lands inside the accessory subtree. + let composerContent = UIView() + harness.view.mountComposerView(composerContent) + #expect(composerContent.isDescendant(of: accessory)) + // The typing responder shares the exact same accessory instance, so + // the dock transfers seamlessly between keyboard owners. + #expect(harness.view.inputProxyForTesting.inputAccessoryView === accessory) + } + + @Test("hidden chrome withholds the accessory from the keyboard system") + func chromeHiddenReturnsNilAccessory() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + #expect(harness.view.inputAccessoryView != nil) + harness.view.setChromeHidden(true) + #expect(harness.view.inputAccessoryView == nil) + #expect(harness.view.inputProxyForTesting.inputAccessoryView == nil) + harness.view.setChromeHidden(false) + #expect(harness.view.inputAccessoryView != nil) + } + + @Test("after attach without autofocus the surface holds first responder (docked accessory state)") + func attachSeatsSurfaceAsFirstResponder() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + // No cmux text responder owns the keyboard and the chrome is visible, + // so the SURFACE must hold first responder — that is what makes the + // system dock the accessory at the screen bottom. + #expect(harness.view.isFirstResponder) + } + + @Test("a keyboard rise updates the grid overlap model from the tracked window-space overlap") + func keyboardRiseUpdatesOverlapModel() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + deliverKeyboardTransition(coveringBottom: Self.keyboardHeight, to: harness) + + // The view fills the window, so the window-space overlap converts 1:1. + let modelKeyboardHeight = try #require(probeValue(of: harness.view, key: "keyboardHeight")) + #expect(abs(modelKeyboardHeight - Self.keyboardHeight) <= 1) + + // Dismissal releases the model back to zero. + deliverKeyboardTransition(coveringBottom: 0, to: harness) + let released = try #require(probeValue(of: harness.view, key: "keyboardHeight")) + #expect(abs(released) <= 1) + } + + @Test("a view attached after the keyboard came up seats the model from the tracker") + func lateAttachedViewSeatsModelFromTracker() throws { + let harness = try makeHarness(attached: false) + defer { tearDown(harness) } + + // The keyboard transition happens while the view is detached — the + // workspace-switch case. Only the tracker observes it; the view's + // handler never runs for it. + harness.center.post(keyboardNotification(coveringBottom: Self.keyboardHeight)) + attach(harness.view, to: harness.window) + + let modelKeyboardHeight = try #require(probeValue(of: harness.view, key: "keyboardHeight")) + let keyboardUp = try #require(probeValue(of: harness.view, key: "keyboardUp")) + #expect(abs(modelKeyboardHeight - Self.keyboardHeight) <= 1) + // The visibility bit catches up too: the toggle must read hide-keyboard. + #expect(keyboardUp == 1) + } + + @Test("a fresh toolbar is born in the keyboard-down state") + func freshToolbarShowsTheShowKeyboardToggle() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + let accessory = try #require(harness.view.inputAccessoryView) + let toggle = try #require(keyboardToggleButton(in: accessory)) + #expect(toggle.accessibilityLabel == "Show Keyboard") + let keyboardUp = try #require(probeValue(of: harness.view, key: "keyboardUp")) + #expect(keyboardUp == 0) + } + + @Test("re-entering after the keyboard dismissed while detached resets the visibility state") + func reattachAfterDetachedDismissalShowsKeyboardDownState() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + // Keyboard up while the surface is presented. + deliverKeyboardTransition(coveringBottom: Self.keyboardHeight, to: harness) + #expect(try #require(probeValue(of: harness.view, key: "keyboardUp")) == 1) + + // Leave the workspace detail: the surface detaches, THEN the keyboard + // dismisses. Only the tracker observes the dismissal. + harness.view.removeFromSuperview() + harness.center.post(keyboardNotification(coveringBottom: 0)) + + // Re-enter: the visibility bit and the toggle glyph must reflect the + // keyboard-down truth (positions are OS-owned now, so only the state + // is asserted). + attach(harness.view, to: harness.window) + + let keyboardUp = try #require(probeValue(of: harness.view, key: "keyboardUp")) + let accessory = try #require(harness.view.inputAccessoryView) + let toggle = try #require(keyboardToggleButton(in: accessory)) + #expect(keyboardUp == 0) + #expect(toggle.accessibilityLabel == "Show Keyboard") + } +} +#endif diff --git a/scripts/iphone-install-queue.sh b/scripts/iphone-install-queue.sh index 0631b7d3367d..a4264047f0c2 100755 --- a/scripts/iphone-install-queue.sh +++ b/scripts/iphone-install-queue.sh @@ -265,7 +265,7 @@ fail_entry() { # re-reads enqueued_at; when it changed, the newer build is left queued for the # next drain pass instead of being silently deleted or failed. drain_entry() { - local slug="$1" override_device="$2" + local slug="$1" override_device="$2" suppress_launch="${3:-0}" local entry="$PENDING_DIR/$slug" local meta="$entry/meta.json" local app="$entry/cmux.app" @@ -334,6 +334,18 @@ drain_entry() { return $? fi + # A multi-tag backlog must not fight over the phone's screen: each signed + # launch foregrounds its app, suspending the previously launched one seconds + # after it paired (its stream drops and the user finds it disconnected). + # The drain therefore launches ONLY the newest queued build; older ones are + # installed fresh but left unlaunched — they sign in and re-pair on their + # next manual open with their stored credentials. + if [[ "$suppress_launch" == "1" ]]; then + log "installed $bundle_id (launch deferred: a newer queued build owns the screen this drain)" + finish_installed + return $? + fi + if [[ "$no_setup" == "1" || "$no_sign_in" == "1" ]]; then if ! xcrun devicectl device process launch --terminate-existing \ --device "$device_id" "$bundle_id" >>"$LOGS_DIR/drain.log" 2>&1; then @@ -408,10 +420,22 @@ cmd_drain() { local start now installed_tags="" had_failure=0 start="$(date +%s)" while :; do - local slug rc remaining=0 + local slug rc remaining=0 newest_slug="" newest_stamp="" stamp suppress + # Only the most recently enqueued build gets the signed foreground launch + # this pass; see drain_entry's suppress_launch comment. Timestamps are + # ISO-8601 from this Mac's clock, so lexicographic comparison orders them. + for slug in $(pending_slugs); do + stamp="$(meta_field "$PENDING_DIR/$slug/meta.json" enqueued_at 2>/dev/null || true)" + if [[ -n "$stamp" && ( -z "$newest_stamp" || "$stamp" > "$newest_stamp" ) ]]; then + newest_stamp="$stamp" + newest_slug="$slug" + fi + done for slug in $(pending_slugs); do + suppress=0 + [[ -n "$newest_slug" && "$slug" != "$newest_slug" ]] && suppress=1 set +e - drain_entry "$slug" "$override_device" + drain_entry "$slug" "$override_device" "$suppress" rc=$? set -e case "$rc" in