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. private enum KeyBarMetrics { /// A hit-target-tall row plus a little breathing room (44 + 8). static let barHeight: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8 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 ) } // 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? private let voice: KeyBarVoiceHandlers? /// - Parameter 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). init(voice: KeyBarVoiceHandlers? = nil) { self.voice = voice super.init( frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight), inputViewStyle: .keyboard ) allowsSelfSizing = true buildBar() } @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) } 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.) private func makeKeycap( label: String, caption: String, title: String, isPrimary: Bool ) -> UIButton { var config: UIButton.Configuration = isPrimary ? .tinted() : .gray() config.cornerStyle = .fixed config.background.cornerRadius = DS.Radius.sm8 var attributedLabel = AttributedString(label) attributedLabel.font = UIFont.monospacedSystemFont( ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize, weight: .semibold ) config.attributedTitle = attributedLabel var attributedCaption = AttributedString(caption) attributedCaption.font = UIFont.preferredFont(forTextStyle: .caption2) attributedCaption.foregroundColor = .secondaryLabel config.attributedSubtitle = attributedCaption config.titleAlignment = .center config.contentInsets = KeyBarMetrics.buttonInsets 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 } }