Keep the iOS terminal dock pinned during keyboard reversals (#9836)

* Add standalone iOS keyboard pinning lab

* Test rapid iOS keyboard dock reversals

* Unify iOS keyboard dock presentation

* Fix CLI compile break from classify() tuple access

https://github.com/manaflow-ai/cmux/pull/9804 landed
`FeedEventClassifier.classify(...).0` while classify() already returned
the named FeedEventClassification struct, so CLI/cmux.swift no longer
compiles on main (every app-host and tests-build-and-lag CI job fails
with "value of type 'FeedEventClassification' has no member '0'").
Use .hookEventName, matching the other call site.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Strengthen rapid keyboard dock coverage

* test(panes): drop stale MobileInjectedAttachStartupTests referencing removed API

The main merge replaced MobileStartupConnectionCoordinator's
connectInjectedAttach with the claim/finish lifecycle, and
DogfoodAttachPreparationTests already covers that lifecycle end to end.
The stale file kept the whole CmuxMobileShellUITests target from
compiling, so no package UI suite could run in CI.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Scope pairing scanner guidance copy onto MobilePairingScannerSheet

The caseless MobilePairingScannerGuidanceCopy enum (from #9493) trips the
namespace-enum rule in scripts/lint-ios-package-conventions.sh, turning the
package-conventions-lint job red for every branch that touches Packages/.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Scope keyboard dock seam measurement to transitions

* Scope dock seam metric to keyboard transitions

* Test whole dock during keyboard reversal

* Isolate keyboard dock from terminal layout

* Animate hosted keyboard dock reflows

* Localize keyboard pinning lab name

---------

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
Abdulaziz Albahar
2026-08-08 19:47:24 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 24cb551c9f
commit ba47b1dc0d
19 changed files with 1247 additions and 92 deletions
+17
View File
@@ -0,0 +1,17 @@
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
let configuration = UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
configuration.delegateClass = SceneDelegate.self
return configuration
}
}
@@ -0,0 +1,19 @@
import UIKit
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = WorkspaceDetailViewController()
window.overrideUserInterfaceStyle = .dark
window.makeKeyAndVisible()
self.window = window
}
}
@@ -0,0 +1,354 @@
import OSLog
import UIKit
@MainActor
final class WorkspaceDetailViewController: UIViewController {
private let logger = Logger(subsystem: "ai.manaflow.KeyboardPinningLab", category: "Keyboard")
private let terminalView = TerminalCanvasView()
private let dockView = ComposerDockView()
private let headerView = WorkspaceHeaderView()
private var stressTask: Task<Void, Never>?
override func viewDidLoad() {
super.viewDidLoad()
configureHierarchy()
configureKeyboardPinning()
configureActions()
observeKeyboardFrames()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
dockView.focusComposer()
if UserDefaults.standard.bool(forKey: "stressKeyboard") {
runStressSequence()
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
stressTask?.cancel()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let guideTop = view.keyboardLayoutGuide.layoutFrame.minY
let dockBottom = dockView.frame.maxY
let gap = guideTop - dockBottom
headerView.updatePinGap(gap)
}
private func configureHierarchy() {
view.backgroundColor = UIColor(red: 0.075, green: 0.078, blue: 0.082, alpha: 1)
[headerView, terminalView, dockView].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
view.addSubview($0)
}
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
headerView.heightAnchor.constraint(equalToConstant: 60),
terminalView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
terminalView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
terminalView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
terminalView.bottomAnchor.constraint(equalTo: dockView.topAnchor),
dockView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
dockView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
}
private func configureKeyboardPinning() {
let keyboardGuide = view.keyboardLayoutGuide
keyboardGuide.followsUndockedKeyboard = true
// The dock and keyboard share one UIKit constraint graph. UIKit owns the
// keyboard's presentation frame and interruptible animation, so no copied
// keyboard height or separately-timed animation can diverge.
dockView.bottomAnchor.constraint(equalTo: keyboardGuide.topAnchor).isActive = true
}
private func configureActions() {
terminalView.onTap = { [weak self] in
self?.dockView.focusComposer()
}
dockView.onKeyboardToggle = { [weak self] in
self?.toggleKeyboard()
}
headerView.onStress = { [weak self] in
self?.runStressSequence()
}
}
private func observeKeyboardFrames() {
NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let self,
let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
self.logger.debug("Keyboard target minY: \(frame.minY, format: .fixed(precision: 1))")
}
}
private func toggleKeyboard() {
if dockView.isComposerFocused {
dockView.dismissComposer()
} else {
dockView.focusComposer()
}
}
private func runStressSequence() {
stressTask?.cancel()
stressTask = Task { [weak self] in
guard let self else { return }
for _ in 0..<20 {
guard !Task.isCancelled else { return }
self.toggleKeyboard()
try? await Task.sleep(for: .milliseconds(135))
}
guard !Task.isCancelled else { return }
self.dockView.focusComposer()
}
}
}
private final class WorkspaceHeaderView: UIView {
var onStress: (() -> Void)?
private let statusLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(red: 0.075, green: 0.078, blue: 0.082, alpha: 0.98)
let backButton = Self.symbolButton("chevron.left")
let titleLabel = UILabel()
titleLabel.text = String(localized: "workspace.title", defaultValue: "cmux DEV lab")
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.textColor = .white
let terminalButton = Self.symbolButton("rectangle.on.rectangle")
let stressButton = Self.symbolButton("arrow.trianglehead.2.clockwise.rotate.90")
stressButton.accessibilityIdentifier = "stressKeyboard"
stressButton.accessibilityLabel = String(localized: "stress.accessibility", defaultValue: "Rapidly toggle keyboard 20 times")
stressButton.addAction(UIAction { [weak self] _ in self?.onStress?() }, for: .touchUpInside)
statusLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
statusLabel.textColor = UIColor(red: 0.31, green: 0.84, blue: 0.53, alpha: 1)
statusLabel.textAlignment = .center
statusLabel.accessibilityIdentifier = "pinGapStatus"
let row = UIStackView(arrangedSubviews: [backButton, titleLabel, UIView(), statusLabel, stressButton, terminalButton])
row.axis = .horizontal
row.alignment = .center
row.spacing = 10
row.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
NSLayoutConstraint.activate([
row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
row.centerYAnchor.constraint(equalTo: centerYAnchor),
statusLabel.widthAnchor.constraint(equalToConstant: 105),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { nil }
func updatePinGap(_ gap: CGFloat) {
let clamped = abs(gap) < 0.05 ? 0 : gap
let formattedGap = Double(clamped).formatted(.number.precision(.fractionLength(1)))
statusLabel.text = String(localized: "PIN GAP \(formattedGap) pt")
statusLabel.textColor = abs(clamped) < 0.1 ? UIColor(red: 0.31, green: 0.84, blue: 0.53, alpha: 1) : .systemRed
}
private static func symbolButton(_ symbol: String) -> UIButton {
var configuration = UIButton.Configuration.plain()
configuration.image = UIImage(systemName: symbol)
configuration.baseForegroundColor = .white
return UIButton(configuration: configuration)
}
}
private final class TerminalCanvasView: UIView {
var onTap: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(red: 0.105, green: 0.108, blue: 0.112, alpha: 1)
isAccessibilityElement = true
accessibilityIdentifier = "terminalCanvas"
accessibilityLabel = String(localized: "terminal.accessibility", defaultValue: "Terminal. Tap to show keyboard.")
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tapGesture)
let terminalText = UILabel()
terminalText.translatesAutoresizingMaskIntoConstraints = false
terminalText.numberOfLines = 0
terminalText.font = .monospacedSystemFont(ofSize: 11, weight: .regular)
terminalText.textColor = UIColor(white: 0.79, alpha: 1)
terminalText.text = String(localized: "terminal.sample", defaultValue: "Last login: Fri Aug 7 19:45:12 on ttys006\n\n~/cmux git:(feat/keyboard-pinning-lab)\n")
addSubview(terminalText)
NSLayoutConstraint.activate([
terminalText.topAnchor.constraint(equalTo: topAnchor, constant: 14),
terminalText.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
terminalText.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -12),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { nil }
@objc private func handleTap() {
onTap?()
}
}
private final class ComposerDockView: UIView, UITextFieldDelegate {
var onKeyboardToggle: (() -> Void)?
private let textField = UITextField()
var isComposerFocused: Bool { textField.isFirstResponder }
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(red: 0.075, green: 0.078, blue: 0.082, alpha: 0.99)
let shortcuts = makeShortcutBar()
let composer = makeComposerBar()
let stack = UIStackView(arrangedSubviews: [shortcuts, composer])
stack.axis = .vertical
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: topAnchor, constant: 6),
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6),
shortcuts.heightAnchor.constraint(equalToConstant: 36),
composer.heightAnchor.constraint(equalToConstant: 42),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { nil }
func focusComposer() {
textField.becomeFirstResponder()
}
func dismissComposer() {
textField.resignFirstResponder()
}
private func makeShortcutBar() -> UIView {
let specs: [(String, String)] = [
("keyboard", "keyboard.toggle"),
("circle.fill", "shortcut.control"),
("square.and.pencil", "shortcut.command"),
("chevron.up", "shortcut.up"),
("option", "shortcut.option"),
("command", "shortcut.command"),
("doc.on.clipboard", "shortcut.paste"),
]
let buttons = specs.map { symbol, identifier in
var configuration = UIButton.Configuration.plain()
configuration.image = UIImage(systemName: symbol)
configuration.baseForegroundColor = .white
configuration.contentInsets = .zero
let button = UIButton(configuration: configuration)
button.accessibilityIdentifier = identifier
if identifier == "keyboard.toggle" {
button.addAction(UIAction { [weak self] _ in self?.onKeyboardToggle?() }, for: .touchUpInside)
}
return button
}
var tabConfiguration = UIButton.Configuration.filled()
tabConfiguration.title = String(localized: "shortcut.tab", defaultValue: "Tab")
tabConfiguration.baseBackgroundColor = UIColor(white: 0.18, alpha: 1)
tabConfiguration.baseForegroundColor = .white
tabConfiguration.cornerStyle = .capsule
tabConfiguration.contentInsets = .init(top: 0, leading: 2, bottom: 0, trailing: 2)
tabConfiguration.titleLineBreakMode = .byClipping
tabConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 11, weight: .medium)
return attributes
}
let tabButton = UIButton(configuration: tabConfiguration)
var escapeConfiguration = tabConfiguration
escapeConfiguration.title = String(localized: "shortcut.escape", defaultValue: "Esc")
let escapeButton = UIButton(configuration: escapeConfiguration)
let stack = UIStackView(arrangedSubviews: buttons + [tabButton, escapeButton])
stack.axis = .horizontal
stack.alignment = .fill
stack.distribution = .fillEqually
stack.spacing = 4
return stack
}
private func makeComposerBar() -> UIView {
let attachment = Self.circleButton(symbol: "paperclip")
let microphone = Self.circleButton(symbol: "mic")
let send = Self.circleButton(symbol: "arrow.up", filled: true)
textField.delegate = self
textField.placeholder = String(localized: "composer.placeholder", defaultValue: "Message")
textField.textColor = .white
textField.tintColor = .white
textField.font = .preferredFont(forTextStyle: .body)
textField.returnKeyType = .send
textField.autocorrectionType = .no
textField.accessibilityIdentifier = "composerTextField"
let fieldContainer = UIView()
fieldContainer.backgroundColor = UIColor(white: 0.12, alpha: 1)
fieldContainer.layer.cornerRadius = 17
textField.translatesAutoresizingMaskIntoConstraints = false
fieldContainer.addSubview(textField)
NSLayoutConstraint.activate([
textField.leadingAnchor.constraint(equalTo: fieldContainer.leadingAnchor, constant: 12),
textField.trailingAnchor.constraint(equalTo: fieldContainer.trailingAnchor, constant: -8),
textField.topAnchor.constraint(equalTo: fieldContainer.topAnchor),
textField.bottomAnchor.constraint(equalTo: fieldContainer.bottomAnchor),
])
let row = UIStackView(arrangedSubviews: [attachment, microphone, fieldContainer, send])
row.axis = .horizontal
row.alignment = .fill
row.spacing = 6
return row
}
private static func circleButton(symbol: String, filled: Bool = false) -> UIButton {
var configuration = filled ? UIButton.Configuration.filled() : UIButton.Configuration.plain()
configuration.image = UIImage(systemName: symbol)
configuration.baseForegroundColor = filled ? .black : .white
configuration.baseBackgroundColor = filled ? UIColor(white: 0.85, alpha: 1) : .clear
configuration.cornerStyle = .capsule
configuration.contentInsets = .zero
return UIButton(configuration: configuration)
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.text = nil
return false
}
}
+11
View File
@@ -0,0 +1,11 @@
# Verification
Source reference: `ScreenRecording_08-07-2026 19-50-56_1.MP4`, 1320×2868, 60 fps, 51.62 seconds.
Invariant: during interrupted rapid keyboard show and hide cycles, the Shortcut bar and Composer bar remain one rigid dock. The dock bottom stays coincident with the keyboard top, with no transient gap, overlap, lag, or snap-back.
Scenario: iPhone 17 Pro Max simulator on iOS 26.5, dark appearance, keyboard initially shown, 20 first-responder reversals at 135 ms intervals, then a final focused state. This interval is shorter than a normal keyboard transition and forces animation interruption.
Result: the live layout diagnostic remained `PIN GAP 0.0 pt`. The final run was sampled at 15 fps, including steady, partial-hide, hidden, partial-show, reversed, and final frames. The invariant held in every sampled frame.
Durable evidence is stored at `cmux-assets/feat-keyboard-pinning-lab/rapid-toggle-final/` in the cmuxterm-hq checkout. The directory contains the raw recording, annotated frames, contact sheet, and manifest with the success criterion.
@@ -0,0 +1,351 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
1E81F0EEF337DE057CCEC892 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = CE0E17A1008C3577E9CD4995 /* InfoPlist.strings */; };
7BCC8A0872603104E124D03E /* WorkspaceDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12AF82959FE7DAAA5C3ADBEE /* WorkspaceDetailViewController.swift */; };
8F1F331AA4DD22756ED437F4 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 949EB7D848CCC51B60D11989 /* Localizable.xcstrings */; };
DE8D30BAEC473E45B80219A5 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2728F872D5FB8D33A41A6E91 /* SceneDelegate.swift */; };
EFCF59C7ED8878A5FAA7DDF5 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4444F6FE9025CD71B41223BE /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
12AF82959FE7DAAA5C3ADBEE /* WorkspaceDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDetailViewController.swift; sourceTree = "<group>"; };
2728F872D5FB8D33A41A6E91 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
2C03363774959A68994988EB /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = "<group>"; };
4444F6FE9025CD71B41223BE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
5405DD1047FED983298B8CD3 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
949EB7D848CCC51B60D11989 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
D4959730D56E1DB02C931108 /* KeyboardPinningLab.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KeyboardPinningLab.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
0CA61C38419BE31AC5D5455E /* App */ = {
isa = PBXGroup;
children = (
4444F6FE9025CD71B41223BE /* AppDelegate.swift */,
2728F872D5FB8D33A41A6E91 /* SceneDelegate.swift */,
12AF82959FE7DAAA5C3ADBEE /* WorkspaceDetailViewController.swift */,
);
path = App;
sourceTree = "<group>";
};
CD6A0D068D8B8C2D583490C8 = {
isa = PBXGroup;
children = (
0CA61C38419BE31AC5D5455E /* App */,
E0F87851FC78526801069DE5 /* Resources */,
E6422832B55FB3758A111DC2 /* Products */,
);
sourceTree = "<group>";
};
E0F87851FC78526801069DE5 /* Resources */ = {
isa = PBXGroup;
children = (
949EB7D848CCC51B60D11989 /* Localizable.xcstrings */,
CE0E17A1008C3577E9CD4995 /* InfoPlist.strings */,
);
path = Resources;
sourceTree = "<group>";
};
E6422832B55FB3758A111DC2 /* Products */ = {
isa = PBXGroup;
children = (
D4959730D56E1DB02C931108 /* KeyboardPinningLab.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
27AE499E18794152BA4672C0 /* KeyboardPinningLab */ = {
isa = PBXNativeTarget;
buildConfigurationList = 76AA4EB0FA9193B6C03BEA00 /* Build configuration list for PBXNativeTarget "KeyboardPinningLab" */;
buildPhases = (
668F9A8ACF9F66A71F3435F3 /* Sources */,
AF47256CFA451165B9D69F33 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = KeyboardPinningLab;
packageProductDependencies = (
);
productName = KeyboardPinningLab;
productReference = D4959730D56E1DB02C931108 /* KeyboardPinningLab.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
58E3B7797EDEB766152AADC7 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
27AE499E18794152BA4672C0 = {
DevelopmentTeam = 7WLXT3NR37;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 6AE1CD5DF1B492DCA42ECF57 /* Build configuration list for PBXProject "KeyboardPinningLab" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
ja,
);
mainGroup = CD6A0D068D8B8C2D583490C8;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = E6422832B55FB3758A111DC2 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
27AE499E18794152BA4672C0 /* KeyboardPinningLab */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
AF47256CFA451165B9D69F33 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1E81F0EEF337DE057CCEC892 /* InfoPlist.strings in Resources */,
8F1F331AA4DD22756ED437F4 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
668F9A8ACF9F66A71F3435F3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
EFCF59C7ED8878A5FAA7DDF5 /* AppDelegate.swift in Sources */,
DE8D30BAEC473E45B80219A5 /* SceneDelegate.swift in Sources */,
7BCC8A0872603104E124D03E /* WorkspaceDetailViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
CE0E17A1008C3577E9CD4995 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
5405DD1047FED983298B8CD3 /* en */,
2C03363774959A68994988EB /* ja */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
0E30ABD0849A82BD34D9BF72 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 7WLXT3NR37;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 6.0;
};
name = Release;
};
C0E137783EBF25A0C576D57F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pinning Lab";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = ai.manaflow.KeyboardPinningLab;
PRODUCT_NAME = "Keyboard Pinning Lab";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
DF22FCDD84F72C666DC4079C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pinning Lab";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = ai.manaflow.KeyboardPinningLab;
PRODUCT_NAME = "Keyboard Pinning Lab";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
EE990F81439C9DDADF595351 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 7WLXT3NR37;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 6.0;
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
6AE1CD5DF1B492DCA42ECF57 /* Build configuration list for PBXProject "KeyboardPinningLab" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EE990F81439C9DDADF595351 /* Debug */,
0E30ABD0849A82BD34D9BF72 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
76AA4EB0FA9193B6C03BEA00 /* Build configuration list for PBXNativeTarget "KeyboardPinningLab" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DF22FCDD84F72C666DC4079C /* Debug */,
C0E137783EBF25A0C576D57F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 58E3B7797EDEB766152AADC7 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "27AE499E18794152BA4672C0"
BuildableName = "KeyboardPinningLab.app"
BlueprintName = "KeyboardPinningLab"
ReferencedContainer = "container:KeyboardPinningLab.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "27AE499E18794152BA4672C0"
BuildableName = "KeyboardPinningLab.app"
BlueprintName = "KeyboardPinningLab"
ReferencedContainer = "container:KeyboardPinningLab.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "27AE499E18794152BA4672C0"
BuildableName = "KeyboardPinningLab.app"
BlueprintName = "KeyboardPinningLab"
ReferencedContainer = "container:KeyboardPinningLab.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "27AE499E18794152BA4672C0"
BuildableName = "KeyboardPinningLab.app"
BlueprintName = "KeyboardPinningLab"
ReferencedContainer = "container:KeyboardPinningLab.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+11
View File
@@ -0,0 +1,11 @@
# Keyboard Pinning Lab
This standalone iOS app isolates the Workspace Detail keyboard geometry from cmux state and networking.
The Composer and Shortcut bars live in one `ComposerDockView`. Its bottom edge is constrained directly to `UIKeyboardLayoutGuide.topAnchor`. UIKit therefore owns the keyboard frame, the dock frame, and interrupted animation timing in one constraint graph.
The circular-arrow header button reverses first-responder state every 135 ms to stress interrupted keyboard transitions. The header reports the live constraint gap. Green `PIN GAP 0.0 pt` means the dock and keyboard guide are coincident in the current layout pass.
Tapping the terminal canvas focuses the same Composer text field, so terminal tap, direct Composer tap, the keyboard button, and the stress control all exercise one keyboard ownership path.
Generate the Xcode project with `xcodegen generate`, then build the `KeyboardPinningLab` scheme.
@@ -0,0 +1,54 @@
{
"sourceLanguage" : "en",
"strings" : {
"composer.placeholder" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Message" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "メッセージ" } }
}
},
"PIN GAP %@ pt" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "PIN GAP %1$@ pt" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "固定間隔 %1$@ pt" } }
}
},
"shortcut.escape" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Esc" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "Esc" } }
}
},
"stress.accessibility" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Rapidly toggle keyboard 20 times" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "キーボードを20回すばやく切り替える" } }
}
},
"shortcut.tab" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Tab" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "Tab" } }
}
},
"terminal.sample" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Last login: Fri Aug 7 19:45:12 on ttys006\n\n~/cmux git:(feat/keyboard-pinning-lab)\n" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "最終ログイン: 8月7日 金 19:45:12 ttys006\n\n~/cmux git:(feat/keyboard-pinning-lab)\n" } }
}
},
"terminal.accessibility" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Terminal. Tap to show keyboard." } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "ターミナル。タップしてキーボードを表示します。" } }
}
},
"workspace.title" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "cmux DEV lab" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "cmux DEV ラボ" } }
}
}
},
"version" : "1.0"
}
@@ -0,0 +1 @@
"CFBundleDisplayName" = "Pinning Lab";
@@ -0,0 +1 @@
"CFBundleDisplayName" = "ピン留めラボ";
+32
View File
@@ -0,0 +1,32 @@
name: KeyboardPinningLab
options:
bundleIdPrefix: ai.manaflow
deploymentTarget:
iOS: "17.0"
generateEmptyDirectories: true
settings:
base:
DEVELOPMENT_TEAM: 7WLXT3NR37
SWIFT_VERSION: 6.0
SWIFT_STRICT_CONCURRENCY: complete
targets:
KeyboardPinningLab:
type: application
platform: iOS
sources:
- path: App
- path: Resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: ai.manaflow.KeyboardPinningLab
PRODUCT_NAME: Keyboard Pinning Lab
INFOPLIST_KEY_CFBundleDisplayName: Pinning Lab
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: true
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: true
INFOPLIST_KEY_UILaunchScreen_Generation: true
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
TARGETED_DEVICE_FAMILY: 1
GENERATE_INFOPLIST_FILE: true
CODE_SIGN_STYLE: Automatic
scheme:
testTargets: []
@@ -9,8 +9,8 @@ import CmuxMobileTerminal
import SwiftUI
import UIKit
/// Mounts a `GhosttySurfaceView`, routes terminal output, and bridges the SwiftUI
/// composer into the surface-owned bottom dock. Primary-screen output uses the
/// Mounts a `GhosttySurfaceHostView`, routes terminal output, and bridges the SwiftUI
/// composer into the host-owned bottom dock. Primary-screen output uses the
/// phone's natural height; alternate-screen replay can pin to the Mac's grid.
struct GhosttySurfaceRepresentable: UIViewRepresentable {
let workspaceID: String
@@ -113,7 +113,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
view.setComposerActive(isComposerActive)
context.coordinator.setComposerMounted(isComposerActive)
context.coordinator.themeApplicationScheduler.seed(generation: configThemeGeneration)
return view
return GhosttySurfaceHostView(surfaceView: view)
}
func updateUIView(_ uiView: UIView, context: Context) {
@@ -123,7 +123,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
// coordinator mounts/unmounts the hosted compose field into the surface's
// composer band. This is a UIKit-internal mutation, not a sibling-observed
// state write, so it is safe in `updateUIView`.
guard let surfaceView = uiView as? GhosttySurfaceView else { return }
guard let surfaceView = (uiView as? GhosttySurfaceHostView)?.surfaceView else { return }
surfaceView.autoFocusOnWindowAttach = autoFocusOnWindowAttach
surfaceView.terminalTheme = terminalTheme
surfaceView.terminalConfigTheme = terminalConfigTheme
@@ -160,7 +160,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
}
static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) {
(uiView as? GhosttySurfaceView)?.prepareForDismantle()
(uiView as? GhosttySurfaceHostView)?.surfaceView.prepareForDismantle()
coordinator.tearDownArtifactChip()
coordinator.tearDownComposer()
coordinator.detach()
@@ -26,7 +26,7 @@ struct MobilePairingScannerPreview: View {
.frame(maxWidth: 280, maxHeight: 280)
.aspectRatio(1, contentMode: .fit)
Text(MobilePairingScannerGuidanceCopy.text)
Text(MobilePairingScannerSheet.guidanceText)
.font(.headline)
.multilineTextAlignment(.center)
.foregroundStyle(.white)
@@ -50,7 +50,7 @@ struct MobilePairingScannerSheet: View {
}
.ignoresSafeArea(edges: .bottom)
Text(MobilePairingScannerGuidanceCopy.text)
Text(Self.guidanceText)
.font(.footnote.weight(.medium))
.multilineTextAlignment(.center)
.foregroundStyle(.white)
@@ -181,8 +181,8 @@ struct MobilePairingScannerSheet: View {
}
#endif
enum MobilePairingScannerGuidanceCopy {
static var text: String {
extension MobilePairingScannerSheet {
static var guidanceText: String {
L10n.string(
"mobile.pairing.scannerInstruction",
defaultValue: """
@@ -1,41 +0,0 @@
import CmuxMobileShellModel
import Testing
@testable import CmuxMobileShellUI
@Suite
struct MobileInjectedAttachStartupTests {
@Test
@MainActor
func beginsRouteAdmissionWithoutAnExternalTransportReadinessBarrier() async 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)
#expect(coordinator.claimStoredReconnect() == nil)
}
}
private actor MobileInjectedAttachURLRecorder {
private var urls: [String] = []
func record(_ url: String) {
urls.append(url)
}
func values() -> [String] {
urls
}
}
@@ -0,0 +1,34 @@
#if canImport(UIKit)
import UIKit
/// Plain UIKit root that owns the terminal's keyboard guide and bottom dock.
///
/// `GhosttySurfaceView` is an aggressively relaid-out `CAMetalLayer` renderer. Keeping
/// the keyboard constraint on this separate, layout-passive root prevents renderer
/// geometry updates from committing an interrupted keyboard transition's model frame.
@MainActor
public final class GhosttySurfaceHostView: UIView {
public let surfaceView: GhosttySurfaceView
public init(surfaceView: GhosttySurfaceView) {
self.surfaceView = surfaceView
super.init(frame: surfaceView.frame)
backgroundColor = surfaceView.backgroundColor
surfaceView.translatesAutoresizingMaskIntoConstraints = false
addSubview(surfaceView)
NSLayoutConstraint.activate([
surfaceView.topAnchor.constraint(equalTo: topAnchor),
surfaceView.leadingAnchor.constraint(equalTo: leadingAnchor),
surfaceView.trailingAnchor.constraint(equalTo: trailingAnchor),
surfaceView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
surfaceView.moveBottomDock(to: self)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
}
#endif
@@ -353,12 +353,15 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
let pointValue: (CGFloat) -> String = {
String(format: "%.3f", Double($0))
}
let toolbarFrame = dockedToolbar?.frame
let toolbarFrame = dockedToolbarFrameInSurface
let composerFrame = composerContainer.convert(composerContainer.bounds, to: self)
let toolbarMinY = toolbarFrame.map { pointValue($0.minY) } ?? "none"
let toolbarMaxY = toolbarFrame.map { pointValue($0.maxY) } ?? "none"
let internalPresentationGap = pointValue(currentInternalDockPresentationGap)
let maximumInternalPresentationGap = pointValue(maximumInternalDockPresentationGap)
let keyboardTransitionID = bottomDockTransitionInFlight ? 1 : -1
let keyboardTransitionTarget = pointValue(keyboardHeight)
let keyboardGuideTop = pointValue(keyboardLayoutGuide.layoutFrame.minY)
let keyboardGuideTop = pointValue(keyboardGuideFrameInSurface.minY)
return [
"chromeHidden=\(chromeHidden ? 1 : 0)",
"composerActive=\(composerActive ? 1 : 0)",
@@ -370,10 +373,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
"inputScene=\(inputScene)",
"inputModal=\(inputModal)",
"keyboardHeight=\(pointValue(keyboardHeight))",
"composerMinY=\(pointValue(composerContainer.frame.minY))",
"composerMaxY=\(pointValue(composerContainer.frame.maxY))",
"composerMinY=\(pointValue(composerFrame.minY))",
"composerMaxY=\(pointValue(composerFrame.maxY))",
"toolbarMinY=\(toolbarMinY)",
"toolbarMaxY=\(toolbarMaxY)",
"dockInternalPresentationGap=\(internalPresentationGap)",
"dockMaxInternalPresentationGap=\(maximumInternalPresentationGap)",
"bottomSafeArea=\(pointValue(safeAreaInsetsBottom))",
"keyboardGuideTop=\(keyboardGuideTop)",
"keyboardTransitionID=\(keyboardTransitionID)",
@@ -511,12 +516,15 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
/// keeps its display-link viewport updates alive until the guide-constrained
/// toolbar's presentation frame reaches its model frame.
private var bottomDockTransitionObserved = false
private var composerBottomToKeyboardConstraint: NSLayoutConstraint?
private var bottomDockToKeyboardConstraint: NSLayoutConstraint?
private var bottomDockHostConstraints: [NSLayoutConstraint] = []
private weak var bottomDockHostView: UIView?
private var composerHeightConstraint: NSLayoutConstraint?
private var toolbarHeightConstraint: NSLayoutConstraint?
#if DEBUG
private var keyboardHeightOverrideForTesting: CGFloat?
private var composerBottomForTestingConstraint: NSLayoutConstraint?
private var bottomDockForTestingConstraint: NSLayoutConstraint?
private var maximumInternalDockPresentationGap: CGFloat = 0
#endif
#if DEBUG
@@ -576,7 +584,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
setKeyboardHeightOverrideForTesting(height)
layoutRenderedTerminalForCurrentViewport()
layoutBottomDock()
layoutIfNeeded()
layoutBottomDockHierarchyIfNeeded()
syncSurfaceGeometry(shouldReassertNaturalSize: true)
}
@@ -766,6 +774,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
addSubview(composerDockProbe)
#endif
configureKeyboardLayoutGuide()
installBottomDockContainer()
installPersistentToolbar()
installComposerContainer()
installBottomDockConstraints()
@@ -898,9 +907,12 @@ 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 single visual dock translated by UIKit's keyboard guide. The Shortcut and
/// Composer bars are children of this view, so an interrupted keyboard animation
/// cannot leave their presentation layers on different translation timelines.
private let bottomDockContainer = UIView()
/// The docked accessory bar. It is the upper child of ``bottomDockContainer``;
/// the composer is the lower child nearest the keyboard.
private weak var dockedToolbar: UIView?
/// Whether the iMessage-style composer is currently open. The surface owns the
/// whole bottom dock (terminal grid / toolbar / composer band / keyboard) in ONE
@@ -994,6 +1006,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
/// constrained toolbar's presentation frame until it reaches the model target.
private func advanceBottomDockTransition() {
let isTransitioning = bottomDockTransitionInFlight
#if DEBUG
sampleInternalDockPresentationGap()
#endif
guard isTransitioning || bottomDockTransitionObserved else { return }
bottomDockTransitionObserved = isTransitioning
layoutRenderedTerminalForCurrentViewport()
@@ -1004,45 +1019,95 @@ 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
private func configureKeyboardLayoutGuide(on owner: UIView? = nil) {
let guide = (owner ?? self).keyboardLayoutGuide
guide.followsUndockedKeyboard = true
guide.usesBottomSafeArea = true
}
/// Pins the whole dock stack to Apple's keyboard guide.
private func installBottomDockContainer() {
bottomDockContainer.backgroundColor = .clear
bottomDockContainer.clipsToBounds = false
bottomDockContainer.layer.zPosition = Self.bottomChromeZPosition
addSubview(bottomDockContainer)
}
/// Pins one dock container to Apple's keyboard guide. Keyboard motion therefore
/// translates one layer; child height constraints only arrange the bars internally.
private func installBottomDockConstraints() {
guard let dockedToolbar else { return }
bottomDockContainer.translatesAutoresizingMaskIntoConstraints = false
dockedToolbar.translatesAutoresizingMaskIntoConstraints = false
composerContainer.translatesAutoresizingMaskIntoConstraints = false
let composerBottom = composerContainer.bottomAnchor.constraint(
let dockBottom = bottomDockContainer.bottomAnchor.constraint(
equalTo: keyboardLayoutGuide.topAnchor
)
let composerHeight = composerContainer.heightAnchor.constraint(equalToConstant: 0)
let toolbarHeight = dockedToolbar.heightAnchor.constraint(equalToConstant: 0)
composerBottomToKeyboardConstraint = composerBottom
bottomDockToKeyboardConstraint = dockBottom
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),
let hostConstraints = [
bottomDockContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
bottomDockContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
dockBottom,
]
bottomDockHostConstraints = hostConstraints
bottomDockHostView = self
NSLayoutConstraint.activate(hostConstraints + [
dockedToolbar.topAnchor.constraint(equalTo: bottomDockContainer.topAnchor),
dockedToolbar.leadingAnchor.constraint(equalTo: bottomDockContainer.leadingAnchor),
dockedToolbar.trailingAnchor.constraint(equalTo: bottomDockContainer.trailingAnchor),
dockedToolbar.bottomAnchor.constraint(equalTo: composerContainer.topAnchor),
composerContainer.leadingAnchor.constraint(equalTo: bottomDockContainer.leadingAnchor),
composerContainer.trailingAnchor.constraint(equalTo: bottomDockContainer.trailingAnchor),
composerContainer.bottomAnchor.constraint(equalTo: bottomDockContainer.bottomAnchor),
composerHeight,
toolbarHeight,
])
layoutBottomDock()
}
/// Moves the visual dock out of the renderer and into a layout-passive host.
/// The host's keyboard guide is then the sole owner of the dock's presentation.
func moveBottomDock(to host: UIView) {
guard host !== self, bottomDockHostView !== host else { return }
configureKeyboardLayoutGuide(on: host)
NSLayoutConstraint.deactivate(bottomDockHostConstraints)
bottomDockContainer.removeFromSuperview()
host.addSubview(bottomDockContainer)
let dockBottom = bottomDockContainer.bottomAnchor.constraint(
equalTo: host.keyboardLayoutGuide.topAnchor
)
let hostConstraints = [
bottomDockContainer.leadingAnchor.constraint(equalTo: host.leadingAnchor),
bottomDockContainer.trailingAnchor.constraint(equalTo: host.trailingAnchor),
dockBottom,
]
bottomDockToKeyboardConstraint = dockBottom
bottomDockHostConstraints = hostConstraints
bottomDockHostView = host
NSLayoutConstraint.activate(hostConstraints)
host.setNeedsLayout()
}
/// Updates the renderer's overlap model from the guide's target top edge.
@discardableResult
private func synchronizeKeyboardGeometryFromLayoutGuide() -> Bool {
let nextHeight = keyboardOverlapFromLayoutGuide
guard abs(nextHeight - keyboardHeight) > 0.25 else { return false }
keyboardHeight = nextHeight
#if DEBUG
// Scope the retained seam maximum to this keyboard target change. The dock
// can already have a presentation layer when the guide updates, so waiting
// to infer a transition from model/presentation divergence may miss its first
// frame and leave an unrelated Composer mount animation in the measurement.
maximumInternalDockPresentationGap = 0
#endif
bottomDockTransitionObserved = bottomDockTransitionInFlight
setNeedsGeometrySync()
return true
@@ -1056,7 +1121,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
}
#endif
guard window != nil, bounds.height > 0 else { return 0 }
let guideFrame = keyboardLayoutGuide.layoutFrame
let guideFrame = keyboardGuideFrameInSurface
// 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
@@ -1067,18 +1132,22 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
return occupancy > safeAreaInsetsBottom + 0.5 ? occupancy : 0
}
private var keyboardGuideFrameInSurface: CGRect {
guard let owner = bottomDockHostView else { return keyboardLayoutGuide.layoutFrame }
return owner.convert(owner.keyboardLayoutGuide.layoutFrame, to: self)
}
/// Whether UIKit is still animating the guide-constrained dock toward its target.
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 {
dockedToolbar?.isHidden == false,
let presentationFrame = bottomDockPresentationFrameInSurface else {
return false
}
return abs(presentationFrame.minY - dockedToolbar.frame.minY) > 0.5
return abs(presentationFrame.minY - bottomDockContainer.frame.minY) > 0.5
}
#if DEBUG
@@ -1089,17 +1158,18 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
keyboardHeight = clamped
bottomDockTransitionObserved = false
composerBottomToKeyboardConstraint?.isActive = false
if composerBottomForTestingConstraint == nil {
composerBottomForTestingConstraint = composerContainer.bottomAnchor.constraint(
equalTo: bottomAnchor
bottomDockToKeyboardConstraint?.isActive = false
if bottomDockForTestingConstraint == nil {
let owner = bottomDockHostView ?? self
bottomDockForTestingConstraint = bottomDockContainer.bottomAnchor.constraint(
equalTo: owner.bottomAnchor
)
}
composerBottomForTestingConstraint?.constant = -TerminalLetterboxGeometry.keyboardOccupancy(
bottomDockForTestingConstraint?.constant = -TerminalLetterboxGeometry.keyboardOccupancy(
keyboardHeight: clamped,
bottomSafeAreaInset: safeAreaInsetsBottom
)
composerBottomForTestingConstraint?.isActive = true
bottomDockForTestingConstraint?.isActive = true
}
#endif
@@ -1118,7 +1188,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
updateDockedToolbarVisibility()
layoutRenderedTerminalForCurrentViewport()
layoutBottomDock()
layoutIfNeeded()
layoutBottomDockHierarchyIfNeeded()
setNeedsGeometrySync()
setNeedsLayout()
}
@@ -1137,7 +1207,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
/// coordinator consumes the same guide-derived overlap for the terminal grid.
private func installPersistentToolbar() {
let toolbar = inputProxy.toolbarView
addSubview(toolbar)
bottomDockContainer.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
@@ -1227,8 +1297,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
bottomSafeAreaInset: safeAreaInsetsBottom,
chromeHidden: chromeHidden,
chromeVisible: dockedToolbarShouldBeVisible && dockedToolbar?.isHidden == false,
toolbarFrame: dockedToolbar?.frame,
toolbarPresentationFrame: dockedToolbar?.layer.presentation()?.frame,
toolbarFrame: dockedToolbarFrameInSurface,
toolbarPresentationFrame: dockedToolbarPresentationFrameInSurface,
viewportNegotiationUnsettled: bottomDockTransitionInFlight
|| pendingViewportReport != nil
|| awaitingViewportEcho
@@ -1422,7 +1492,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
// device dogfood pass conclusive about whether the bar stays visible and
// docks correctly while composing, since the simulator cannot show the
// keyboard. Records the state that decides the bar's frame.
let barFrame = dockedToolbar?.frame ?? .zero
let barFrame = dockedToolbarFrameInSurface ?? .zero
MobileDebugLog.anchormux(
"composer.toggle active=\(active) keyboardHeight=\(Int(keyboardHeight)) occInBounds=\(Int(keyboardOccupancyInBounds)) barHidden=\(dockedToolbar?.isHidden ?? true) barY=\(Int(barFrame.minY)) barH=\(Int(barFrame.height)) boundsH=\(Int(bounds.height))"
)
@@ -1523,7 +1593,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
// z-position as the toolbar.
composerContainer.clipsToBounds = false
composerContainer.layer.zPosition = Self.bottomChromeZPosition
addSubview(composerContainer)
bottomDockContainer.addSubview(composerContainer)
}
/// Mounts the host-built artifact chip inside the terminal's bottom-dock
@@ -1722,7 +1792,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
guard let self else { return }
self.layoutRenderedTerminalForCurrentViewport()
self.layoutBottomDock()
self.layoutIfNeeded()
self.layoutBottomDockHierarchyIfNeeded()
}
if animated {
animateDockReflow(animations: apply, completion: completion)
@@ -1736,6 +1806,52 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
/// Duration (seconds) used for dock reflows when no keyboard transition is active.
private static let composerReflowDuration: TimeInterval = 0.25
/// Toolbar geometry in the surface coordinate system. The toolbar is nested in
/// ``bottomDockContainer``, so its raw `frame` is container-relative.
private var dockedToolbarFrameInSurface: CGRect? {
guard let dockedToolbar, dockedToolbar.superview != nil else { return nil }
return dockedToolbar.convert(dockedToolbar.bounds, to: self)
}
/// Live toolbar geometry including the keyboard-driven presentation transform of
/// its parent dock. Renderer clipping consumes this rather than reconstructing a
/// keyboard animation from notification timing.
private var dockedToolbarPresentationFrameInSurface: CGRect? {
presentationFrameInSurface(of: dockedToolbar)
}
private var bottomDockPresentationFrameInSurface: CGRect? {
presentationFrameInSurface(of: bottomDockContainer)
}
private func presentationFrameInSurface(of view: UIView?) -> CGRect? {
guard let view,
view.superview != nil,
let presentationLayer = view.layer.presentation() else { return nil }
let surfaceLayer = layer.presentation() ?? layer
return presentationLayer.convert(view.bounds, to: surfaceLayer)
}
#if DEBUG
private var currentInternalDockPresentationGap: CGFloat {
guard dockedToolbarShouldBeVisible,
dockedToolbar?.isHidden == false,
!composerContainer.isHidden,
let toolbarFrame = dockedToolbarPresentationFrameInSurface,
let composerFrame = presentationFrameInSurface(of: composerContainer),
toolbarFrame.height > 0.5,
composerFrame.height > 0.5 else { return 0 }
return abs(composerFrame.minY - toolbarFrame.maxY)
}
private func sampleInternalDockPresentationGap() {
maximumInternalDockPresentationGap = max(
maximumInternalDockPresentationGap,
currentInternalDockPresentationGap
)
}
#endif
/// Apply dock heights from the same snapshot the terminal viewport consumes.
private func layoutBottomDock() {
layoutBottomDock(using: viewportSnapshot())
@@ -1750,12 +1866,19 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
layoutArtifactChip(using: snapshot)
}
/// Lays out the hierarchy that owns the dock constraints. Once the dock moves
/// into `GhosttySurfaceHostView`, laying out only the renderer cannot animate
/// composer or toolbar height changes because those constraints are siblings.
private func layoutBottomDockHierarchyIfNeeded() {
(bottomDockHostView ?? self).layoutIfNeeded()
}
/// Animate the whole bottom dock to its current target frames.
private func animateBottomDock() {
animateDockReflow { [weak self] in
guard let self else { return }
self.layoutBottomDock()
self.layoutIfNeeded()
self.layoutBottomDockHierarchyIfNeeded()
self.layoutRenderedTerminalForCurrentViewport()
}
}
+86
View File
@@ -7427,6 +7427,92 @@ final class cmuxUITests: XCTestCase {
}
}
/// Reversing a keyboard dismissal before it settles must keep the Shortcut and
/// Composer bars in one visual dock. The surface samples their presentation-layer
/// seam every display frame; any transient separation remains observable after the
/// animation settles through `dockMaxInternalPresentationGap`.
@MainActor
func testTerminalDockStaysUnifiedAcrossRapidKeyboardReversals() async throws {
let server = try MobileSyncMockHostServer()
let port = try await server.start()
defer { server.stop() }
let app = try launchConnectedApp(port: port)
let surface = app.otherElements["MobileTerminalSurface"]
XCTAssertTrue(surface.waitForExistence(timeout: 8))
let composerField = app.descendants(matching: .any)[Composer.field]
XCTAssertTrue(
composerField.waitForExistence(timeout: 4),
"Rapid reversal coverage requires the Composer bar to be mounted"
)
let initialDock = waitForDock(in: app, describe: "composer and shortcut bars are both visible") {
guard $0["composerActive"] == "1",
let composerMinY = $0["composerMinY"].flatMap(Double.init),
let composerMaxY = $0["composerMaxY"].flatMap(Double.init) else { return false }
return composerMaxY - composerMinY > 1
}
XCTAssertEqual(initialDock["composerActive"], "1")
surface.tap()
guard let initialKeyboard = waitForSoftwareKeyboardKeyPlane(
in: app,
minimumOverlap: 120,
timeout: 4
) else { return }
assertTerminalDockPinnedToSoftwareKeyboard(
surfaceDock(in: app),
surface: surface,
keyboard: initialKeyboard,
context: "rapid-reversal baseline"
)
let composerKeyboardInset = initialKeyboard.frame.minY - composerField.frame.maxY
let hideKeyboardButton = app.buttons["terminal.inputAccessory.hideKeyboard"]
XCTAssertTrue(hideKeyboardButton.waitForExistence(timeout: 4))
for cycle in 1...10 {
hideKeyboardButton.tap()
if app.keyboards.firstMatch.exists {
XCTAssertEqual(
app.keyboards.firstMatch.frame.minY - composerField.frame.maxY,
composerKeyboardInset,
accuracy: 2,
"The whole dock detached while keyboard dismissal was still visible in cycle \(cycle)"
)
}
surface.tap()
guard let keyboard = waitForSoftwareKeyboardKeyPlane(
in: app,
minimumOverlap: 120,
timeout: 4
) else { return }
let dock = surfaceDock(in: app)
XCTAssertEqual(
keyboard.frame.minY - composerField.frame.maxY,
composerKeyboardInset,
accuracy: 2,
"The whole dock detached from the keyboard after rapid reversal \(cycle)"
)
assertTerminalDockPinnedToSoftwareKeyboard(
dock,
surface: surface,
keyboard: keyboard,
context: "rapid reversal \(cycle)"
)
guard let maximumGap = dock["dockMaxInternalPresentationGap"].flatMap(Double.init) else {
XCTFail("Missing per-frame dock seam metric after rapid reversal \(cycle). dock=\(dock)")
return
}
XCTAssertLessThanOrEqual(
maximumGap,
1,
"Shortcut and Composer bars separated during rapid reversal \(cycle). dock=\(dock)"
)
}
}
@MainActor
private func waitForKeyboardDismissal(in app: XCUIApplication) -> Bool {
let expectation = XCTNSPredicateExpectation(