import SessionCore import UIKit /// T-iOS-11 · Mobile key bar + hardware key mapping, mirroring /// `public/keybar.ts` — the Claude Code keys a phone keyboard can't produce, /// most-used first. EVERY label→bytes lookup resolves through /// `KeyByteMap` (SessionCore) — no byte literal lives in this file. /// /// The bar is installed as the terminal's `inputAccessoryView` and sends via /// the ViewModel/engine DIRECTLY (bypassing SwiftTerm's text path so a tap /// never pops or fights the soft keyboard — same reason the web bar bypasses /// xterm and calls `ws.send`). // MARK: - Visibility policy (T-iPad-3) /// Pure predicate deciding whether the soft-keyboard KeyBar /// (`inputAccessoryView`) should show — THE single decision point (mirrors /// `PrivacyShadePolicy` / `LayoutPolicy`, no scattered checks in views): /// - a hardware keyboard makes the on-screen key bar redundant → default hidden; /// - no hardware keyboard → shown (unchanged iPhone behavior — zero regression); /// - an explicit user toggle (`userOverride`) always wins over the auto default. /// /// Hardware presence is injected (`GCKeyboard.coalesced != nil` at the call /// site) so this stays a fast, device-agnostic unit (`KeyBarVisibilityTests`). enum KeyBarVisibility { /// - Parameters: /// - hardwareKeyboardPresent: injected `GCKeyboard.coalesced != nil`. /// - userOverride: nil = follow the auto default; true/false = the user /// explicitly forced show/hide (wins over the hardware-driven default). static func isVisible(hardwareKeyboardPresent: Bool, userOverride: Bool?) -> Bool { if let userOverride { return userOverride } return !hardwareKeyboardPresent } } // MARK: - Layout data (mirror of KEYBAR_BUTTONS) /// One key-bar button: glyph, short function caption (shown under the glyph, /// self-documenting on touch) and the full title for accessibility. struct KeyBarButtonSpec: Equatable, Sendable { let key: KeyByteMap.Key let label: String let caption: String let title: String let isPrimary: Bool init(key: KeyByteMap.Key, label: String, caption: String, title: String, isPrimary: Bool = false) { self.key = key self.label = label self.caption = caption self.title = title self.isPrimary = isPrimary } } /// Push-to-talk outlets for the 🎤 key (T-iOS-31). Supplied as a PAIR — a mic /// that can start but not stop would leave the microphone hot. struct KeyBarVoiceHandlers { let onDown: @MainActor () -> Void let onUp: @MainActor () -> Void } /// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS` /// (most-used Claude Code keys first), plus the 🎤 push-to-talk key /// (T-iOS-31) which mirrors `keybar.ts buildMicButton` — appended at the END, /// only when voice handlers are supplied, and deliberately OUTSIDE /// `buttons`/`KeyByteMap`: it maps to no bytes at all. enum KeyBarLayout { /// 🎤 glyph + caption, transcribed from `keybar.ts buildMicButton`. static let voiceLabel = "🎤" static let voiceCaption = "语音" /// Accessibility title. Unlike the web (Chrome ships audio to Google), iOS /// prefers on-device recognition — the copy says what actually happens. static let voiceTitle = "按住说话 — 转成文字后要你确认才送进终端" static let buttons: [KeyBarButtonSpec] = [ KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断", title: "Esc — interrupt Claude / dismiss", isPrimary: true), KeyBarButtonSpec(key: .escEsc, label: "Esc²", caption: "回溯", title: "Esc Esc — rewind / clear draft"), KeyBarButtonSpec(key: .shiftTab, label: "⇧Tab", caption: "模式", title: "Shift+Tab — cycle plan / auto-accept mode"), KeyBarButtonSpec(key: .arrowUp, label: "↑", caption: "上一个", title: "Up — previous option / history"), KeyBarButtonSpec(key: .arrowDown, label: "↓", caption: "下一个", title: "Down — next option / history"), KeyBarButtonSpec(key: .enter, label: "⏎", caption: "确认", title: "Enter — confirm"), KeyBarButtonSpec(key: .ctrlC, label: "^C", caption: "取消", title: "Ctrl+C — cancel / quit"), KeyBarButtonSpec(key: .ctrlR, label: "^R", caption: "搜历史", title: "Ctrl+R — reverse-search command history"), KeyBarButtonSpec(key: .ctrlO, label: "^O", caption: "详情", title: "Ctrl+O — expand transcript / tool detail"), KeyBarButtonSpec(key: .ctrlL, label: "^L", caption: "重绘", title: "Ctrl+L — redraw screen"), KeyBarButtonSpec(key: .ctrlT, label: "^T", caption: "任务", title: "Ctrl+T — toggle task list"), KeyBarButtonSpec(key: .ctrlB, label: "^B", caption: "后台", title: "Ctrl+B — background running task"), KeyBarButtonSpec(key: .ctrlD, label: "^D", caption: "退出", title: "Ctrl+D — exit session (EOF)"), KeyBarButtonSpec(key: .tab, label: "Tab", caption: "补全", title: "Tab — complete / toggle"), KeyBarButtonSpec(key: .arrowLeft, label: "←", caption: "左移", title: "Left — move cursor left"), KeyBarButtonSpec(key: .arrowRight, label: "→", caption: "右移", title: "Right — move cursor right"), KeyBarButtonSpec(key: .slash, label: "/", caption: "命令", title: "Slash — command launcher"), ] } /// Layout constants — all composed from the frozen `DS` scale (no literals). /// UIKit reads the same CGFloat tokens the SwiftUI chrome uses, so the key bar /// stays on-grid with the rest of the app. /// /// T-iOS-34 acceptance ("亮暗主题 + **最大字号不破版**") · The bar height used /// to be the CONSTANT 44 + 8 = 52pt while the keycap it draws is two lines of /// Dynamic Type text — at AX5 that keycap needs ~108pt, so more than half of it /// was clipped. The height is now DERIVED from the keycap at the user's text /// size, with two bounds that pull in opposite directions and are both required: /// - a 44pt FLOOR, so the small sizes keep exactly today's 52pt bar and the HIG /// hit target survives (zero regression); /// - a CEILING on the text itself (`typeCeiling`), so the bar cannot grow /// without limit. This bar is an `inputAccessoryView` riding on top of the /// soft keyboard: an unclamped AX5 keycap turns a 52pt strip into a ~108pt one /// and, with the keyboard already up, eats the terminal it exists to drive. /// Clamping the FONT (not the frame) is what keeps that honest — the height /// below is computed from the SAME clamped fonts the keycaps render with, so /// nothing is ever clipped at any size. /// /// `internal`, not `private`: the acceptance is a MEASUREMENT /// (`DynamicTypeLayoutTests` compares these against the real UIKit fonts and /// against a real `KeyBarView`), and a private constant could only be /// re-implemented in the test — i.e. not tested at all. enum KeyBarMetrics { static let buttonSpacing: CGFloat = DS.Space.xs4 static let contentInset: CGFloat = DS.Space.sm8 static let buttonInsets = NSDirectionalEdgeInsets( top: DS.Space.xs4, leading: DS.Space.md12, bottom: DS.Space.xs4, trailing: DS.Space.md12 ) /// Dynamic Type ceiling for the keycap text — the UIKit currency of the DS's /// dense-content policy `DS.Typography.numericClamp` (`.accessibility2`, /// whose `UIContentSizeCategory` twin is `.accessibilityLarge`). The two are /// pinned equal by a test so they cannot drift apart. /// /// Prose in this app scales all the way to AX5 and nothing caps it; a /// 17-keycap control strip docked to the keyboard is the same dense case as /// the telemetry chips — past this point extra size only costs terminal rows. static let typeCeiling: UIContentSizeCategory = .accessibilityLarge /// The category the keycaps actually render at: the user's, capped at /// `typeCeiling`. static func effectiveCategory( _ category: UIContentSizeCategory ) -> UIContentSizeCategory { category > typeCeiling ? typeCeiling : category } /// Traits carrying the effective category — the `compatibleWith:` argument /// for every font below. `UIFont.preferredFont(forTextStyle:)` WITHOUT it /// reads the app-wide category and would ignore the clamp entirely. static func traits(_ category: UIContentSizeCategory) -> UITraitCollection { UITraitCollection(preferredContentSizeCategory: effectiveCategory(category)) } /// Glyph line ("Esc", "^C", "⇧Tab"): monospaced footnote, so the glyphs stay /// on a common width grid. static func glyphFont(_ category: UIContentSizeCategory) -> UIFont { UIFont.monospacedSystemFont( ofSize: UIFont.preferredFont( forTextStyle: .footnote, compatibleWith: traits(category) ).pointSize, weight: .semibold ) } /// Caption line ("中断", "模式"): caption2, secondary label colour. static func captionFont(_ category: UIContentSizeCategory) -> UIFont { UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits(category)) } /// What ONE keycap needs at `category`: its two text lines plus the button's /// own vertical content insets. Rounded up — a fractional shortfall is still /// a clipped descender. static func keycapHeight(_ category: UIContentSizeCategory) -> CGFloat { (glyphFont(category).lineHeight + captionFont(category).lineHeight + buttonInsets.top + buttonInsets.bottom).rounded(.up) } /// The bar's height at `category`: whatever the keycap needs, never below the /// 44pt hit target, plus `sm8` of breathing room. At the standard sizes the /// keycap is smaller than 44 ⇒ 44 + 8 = 52pt, identical to the old constant. static func barHeight(_ category: UIContentSizeCategory) -> CGFloat { max(DS.Layout.minHitTarget, keycapHeight(category)) + DS.Space.sm8 } } // MARK: - KeyBarView (inputAccessoryView) /// Horizontally scrolling key bar, installed as the terminal's /// `inputAccessoryView`. Taps call `onKey` — the screen routes them to /// `TerminalViewModel.send(key:)`, which resolves bytes via `KeyByteMap`. final class KeyBarView: UIInputView { /// Tap outlet. `@MainActor`-typed so the handler can drive the ViewModel. var onKey: (@MainActor (KeyByteMap.Key) -> Void)? /// Built buttons in layout order (test-visible). The 🎤 key is NOT in here — /// it sends no bytes, so it stays out of the KeyByteMap-backed list. private(set) var keyButtons: [UIButton] = [] /// The 🎤 push-to-talk key, nil unless voice handlers were supplied. private(set) var voiceButton: UIButton? /// The Dynamic Type category the bar is currently laid out for (T-iOS-34). /// Test-visible: the acceptance is "the keycap the bar DRAWS fits the bar", /// which is only measurable if the category the bar drew at is knowable. private(set) var contentSizeCategory: UIContentSizeCategory private let voice: KeyBarVoiceHandlers? /// - Parameters: /// - voice: press/release outlets for the 🎤 key (T-iOS-31). nil ⇒ no mic /// key at all (mirrors the web bar, which only renders it when speech is /// supported AND a trigger is supplied). /// - contentSizeCategory: the text size to lay out for. Defaults to the /// app's own — the same value `UIFont.preferredFont(forTextStyle:)` /// reads — and is re-read from the traits whenever the user changes it. /// Injectable so the AX5 acceptance can be measured without driving /// Settings (a `UITraitCollection.performAsCurrent` wrapper would NOT /// work: the un-`compatibleWith:` font API ignores it, which is exactly /// the trap the old measurement fell into). init( voice: KeyBarVoiceHandlers? = nil, contentSizeCategory: UIContentSizeCategory = UIApplication.shared.preferredContentSizeCategory ) { self.voice = voice self.contentSizeCategory = contentSizeCategory super.init( frame: CGRect( x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight(contentSizeCategory) ), inputViewStyle: .keyboard ) allowsSelfSizing = true buildBar() // Live text-size changes: re-render the keycaps at the new size and let // `allowsSelfSizing` re-measure the bar. Without this the bar would keep // whatever height it was born with until the terminal is reopened. registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { (bar: KeyBarView, _: UITraitCollection) in bar.apply(contentSizeCategory: bar.traitCollection.preferredContentSizeCategory) } } @available(*, unavailable, message: "KeyBarView is code-built only") required init?(coder: NSCoder) { return nil } override var intrinsicContentSize: CGSize { CGSize( width: UIView.noIntrinsicMetric, height: KeyBarMetrics.barHeight(contentSizeCategory) ) } /// Re-render every keycap at `category` and re-measure the bar. A no-op when /// the category did not actually change (trait callbacks fire for unrelated /// trait changes too). func apply(contentSizeCategory category: UIContentSizeCategory) { guard category != contentSizeCategory else { return } contentSizeCategory = category for (button, spec) in zip(keyButtons, KeyBarLayout.buttons) { button.configuration = keycapConfiguration( label: spec.label, caption: spec.caption, isPrimary: spec.isPrimary ) } voiceButton?.configuration = keycapConfiguration( label: KeyBarLayout.voiceLabel, caption: KeyBarLayout.voiceCaption, isPrimary: false ) frame.size.height = KeyBarMetrics.barHeight(category) invalidateIntrinsicContentSize() } private func buildBar() { let stack = UIStackView() stack.axis = .horizontal stack.spacing = KeyBarMetrics.buttonSpacing stack.alignment = .center stack.translatesAutoresizingMaskIntoConstraints = false for spec in KeyBarLayout.buttons { let button = makeButton(for: spec) keyButtons = keyButtons + [button] stack.addArrangedSubview(button) } if let voice { let mic = makeVoiceButton(voice) voiceButton = mic stack.addArrangedSubview(mic) } let scroll = UIScrollView() scroll.showsHorizontalScrollIndicator = false scroll.translatesAutoresizingMaskIntoConstraints = false scroll.addSubview(stack) addSubview(scroll) NSLayoutConstraint.activate([ scroll.leadingAnchor.constraint(equalTo: leadingAnchor), scroll.trailingAnchor.constraint(equalTo: trailingAnchor), scroll.topAnchor.constraint(equalTo: topAnchor), scroll.bottomAnchor.constraint(equalTo: bottomAnchor), stack.leadingAnchor.constraint( equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: KeyBarMetrics.contentInset ), stack.trailingAnchor.constraint( equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -KeyBarMetrics.contentInset ), stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor), ]) } private func makeButton(for spec: KeyBarButtonSpec) -> UIButton { let button = makeKeycap( label: spec.label, caption: spec.caption, title: spec.title, isPrimary: spec.isPrimary ) button.addAction( UIAction { [weak self] _ in self?.onKey?(spec.key) }, for: .touchUpInside ) return button } /// The 🎤 key: same keycap look, but **press/release** semantics instead of a /// tap, and it never goes through `onKey` (it maps to no bytes at all). /// `.touchUpOutside`/`.touchCancel` also release, so sliding a finger off the /// key can never leave the microphone open. private func makeVoiceButton(_ voice: KeyBarVoiceHandlers) -> UIButton { let button = makeKeycap( label: KeyBarLayout.voiceLabel, caption: KeyBarLayout.voiceCaption, title: KeyBarLayout.voiceTitle, isPrimary: false ) button.addAction(UIAction { _ in voice.onDown() }, for: .touchDown) for event in [UIControl.Event.touchUpInside, .touchUpOutside, .touchCancel] { button.addAction(UIAction { _ in voice.onUp() }, for: event) } return button } /// Shared keycap chrome: primary (Esc) wears the accent tint, the rest a /// subtle gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is /// the UIKit twin of DS.Palette.textSecondary — the DS UIColor surface only /// vends the accent, so native system labels stand in at this boundary.) /// /// Both fonts come from `KeyBarMetrics`, resolved `compatibleWith:` the /// bar's current (clamped) category — the SAME resolution `barHeight` is /// computed from, which is what makes "the bar always fits its keycaps" true /// by construction rather than by a hand-tuned constant. private func keycapConfiguration( label: String, caption: String, isPrimary: Bool ) -> UIButton.Configuration { var config: UIButton.Configuration = isPrimary ? .tinted() : .gray() config.cornerStyle = .fixed config.background.cornerRadius = DS.Radius.sm8 var attributedLabel = AttributedString(label) attributedLabel.font = KeyBarMetrics.glyphFont(contentSizeCategory) config.attributedTitle = attributedLabel var attributedCaption = AttributedString(caption) attributedCaption.font = KeyBarMetrics.captionFont(contentSizeCategory) attributedCaption.foregroundColor = .secondaryLabel config.attributedSubtitle = attributedCaption config.titleAlignment = .center config.contentInsets = KeyBarMetrics.buttonInsets return config } private func makeKeycap( label: String, caption: String, title: String, isPrimary: Bool ) -> UIButton { let config = keycapConfiguration( label: label, caption: caption, isPrimary: isPrimary ) let button = UIButton(configuration: config) if isPrimary { button.tintColor = DS.Palette.accentUIColor() } button.accessibilityLabel = title // HIG minimum touch target regardless of glyph width. button.heightAnchor .constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget) .isActive = true return button } } // MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping) /// Hardware-keyboard chords routed through the SAME `KeyByteMap` mapping /// (plan §7 T-iOS-11). Scope decision (documented deviation): only Esc, /// Shift+Tab and the Ctrl chords are registered — /// - arrows stay SwiftTerm-native: a fixed CSI override would break /// application-cursor-keys mode (DECCKM: vim/htop expect `ESC O A`); /// - Enter/Tab/"/" are ordinary typing SwiftTerm already delivers; /// - Esc·Esc is simply two Esc presses. /// UIKeyCommand interception replaces (never duplicates) the responder's own /// press handling, so each chord is sent exactly once. enum HardwareKeyCommands { struct Chord: Equatable { let key: KeyByteMap.Key let input: String let modifiers: UIKeyModifierFlags } static let chords: [Chord] = [ Chord(key: .esc, input: UIKeyCommand.inputEscape, modifiers: []), Chord(key: .shiftTab, input: "\t", modifiers: .shift), Chord(key: .ctrlC, input: "c", modifiers: .control), Chord(key: .ctrlR, input: "r", modifiers: .control), Chord(key: .ctrlO, input: "o", modifiers: .control), Chord(key: .ctrlL, input: "l", modifiers: .control), Chord(key: .ctrlT, input: "t", modifiers: .control), Chord(key: .ctrlB, input: "b", modifiers: .control), Chord(key: .ctrlD, input: "d", modifiers: .control), ] /// One prioritized `UIKeyCommand` per chord, all firing `action`. /// (`UIKeyCommand` is `@MainActor` in the SDK, hence the isolation.) @MainActor static func build(action: Selector) -> [UIKeyCommand] { chords.map { chord in let command = UIKeyCommand( action: action, input: chord.input, modifierFlags: chord.modifiers ) command.wantsPriorityOverSystemBehavior = true return command } } /// Reverse lookup for the command handler: which chord fired. @MainActor static func key(matching command: UIKeyCommand) -> KeyByteMap.Key? { chords.first { $0.input == command.input && $0.modifiers == command.modifierFlags }?.key } }