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:
co-authored by
Claude Fable 5
parent
24cb551c9f
commit
ba47b1dc0d
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 */;
|
||||
}
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
+95
@@ -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>
|
||||
@@ -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" = "ピン留めラボ";
|
||||
@@ -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: []
|
||||
+5
-5
@@ -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()
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+3
-3
@@ -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: """
|
||||
|
||||
-41
@@ -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
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
+165
-42
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user