Files
OSGKeyboard/OSGKeyboardExt/Views/AIKeyboardView.swift
T
Rocky fe8f657c8d Fix CI tooling and semantic skill recommendations
Resolve the pinned XcodeGen archive layout and let the keyboard derive bounded clipboard recommendations from the full catalog independently of installation state.
2026-08-22 16:46:37 +08:00

1181 lines
43 KiB
Swift

// AIKeyboardView.swift
// OSGKeyboard · Keyboard Extension
//
// Unified assistant surface: tap for ordinary dictation, hold for AI, and use
// the same idle layout for hotwords, clipboard skills, editing, and Send.
import OSGKeyboardShared
import SwiftUI
struct AIKeyboardView: View {
private enum Layout {
static let topBarHeight: CGFloat = 44
static let primaryHeight: CGFloat = 60
static let primaryWidth: CGFloat = 156
static let compactPrimaryHeight: CGFloat = 56
static let compactPrimaryWidth: CGFloat = 148
static let secondaryHeight: CGFloat = 52
static let fieldActionWidth: CGFloat = 132
static let compactIPadFieldActionWidth: CGFloat = 112
static let circleSize: CGFloat = 48
static let compactIPadCircleSize: CGFloat = 44
static let sideButtonEdgeInset: CGFloat = 8
static let actionClusterMaxWidth: CGFloat = 430
static let primaryToSecondaryGap: CGFloat = 25
static let hotwordHeight: CGFloat = 28
static let hotwordMaxWidth: CGFloat = 180
static let skillTipMaxWidth: CGFloat = 300
/// The 30 pt tab sits inside a 44 pt top bar. Half of its bottom inset
/// belongs visually to the label-to-tab gap, so compensate by 3.5 pt.
static let contextVisualOffset: CGFloat = -3.5
static let carouselInterval: TimeInterval = 4
static let skillButtonSize: CGFloat = 48
static let skillCellWidth: CGFloat = 60
static let skillCellSpacing: CGFloat = 10
static let pageDotSize: CGFloat = 5
static let skillPaginationBottomInset: CGFloat = 6
static let maximumSemanticSkills = 5
}
#if DEBUG
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
static var debugPreviewSkills: [AIClipboardSkill]?
/// Keeps the deterministic UI harness on the tappable idle hint.
static var debugSkipsLongPressCoach = false
/// Prevents deterministic feedback previews from expiring mid-assertion.
static var debugKeepsSkillTip = false
#endif
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
@ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared
let onInsert: (String) -> Void
@AppStorage("keyboard.assistant.longPressCoachCount")
private var longPressCoachCount = 0
@State private var showsLongPressCoach = false
@State private var currentHint: AIHintCard?
@State private var hintOpacity: Double = 1
@State private var carouselBag = AIHintCarouselBag()
@State private var poolCards: [AIHintCard] = []
@State private var selectedSkillPage = 0
@State private var dismissedClipboardEntryID: UUID?
@State private var debugSkillsDismissed = false
@State private var micLongPressConsumed = false
@State private var micIsHoldingForAI = false
@State private var fieldActionConfirmationVisible = false
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
var body: some View {
Group {
if state.editSession.isActive {
LastInputEditView(state: state)
} else if state.aiSession.canInsert {
pendingAnswerSurface
} else {
assistantSurface
}
}
.overlay(alignment: .topLeading) {
Color.clear
.frame(width: 1, height: 1)
.accessibilityElement()
.accessibilityIdentifier("assistant.surface")
.accessibilityLabel(ExtL10n.text("keyboard.tab.ai"))
.allowsHitTesting(false)
}
.environment(\.themePalette, palette)
.onAppear {
resetCarousel()
if shouldShowLongPressCoach, longPressCoachCount < 3 {
showsLongPressCoach = true
longPressCoachCount += 1
}
}
.onChange(of: state.aiSession.phase) { _, phase in
if phase == .idle || phase == .failed || phase == .inserted || phase == .sent {
resetCarousel()
}
}
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in
dismissedClipboardEntryID = nil
debugSkillsDismissed = false
selectedSkillPage = 0
resetCarousel()
}
.onChange(of: state.clipboardSkillCatalog) { _, _ in
selectedSkillPage = 0
resetCarousel()
}
.onChange(of: semanticRanking.snapshot) { _, _ in
selectedSkillPage = 0
}
.onChange(of: state.skillTipText) { _, tip in
guard let tip, !tip.isEmpty else { return }
#if DEBUG
guard !Self.debugKeepsSkillTip else { return }
#endif
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(2_800))
if state.skillTipText == tip {
state.skillTipText = nil
}
}
}
.animation(Motion.soft, value: state.skillTipText)
.onReceive(
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
) { _ in
guard assistantIsResting else { return }
reloadHintPool(resetBag: false)
guard !showsClipboardSkills, !showsLongPressCoach else { return }
if reduceMotion, let hint = currentHint,
poolCards.contains(where: { $0.id == hint.id }) {
return
}
showNextHint(animated: !reduceMotion)
}
}
private var shouldShowLongPressCoach: Bool {
#if DEBUG
return !Self.debugSkipsLongPressCoach
#else
return true
#endif
}
private var assistantSurface: some View {
VStack(spacing: 0) {
topBar.frame(height: Layout.topBarHeight)
// This flexible slot is exactly the gap between the top tabs and
// microphone. Centering its content guarantees equal whitespace
// above and below labels on both phone and iPad heights.
contextArea
.frame(maxHeight: .infinity)
.offset(y: Layout.contextVisualOffset)
primaryActionRow.frame(height: Layout.primaryHeight)
Color.clear.frame(height: Layout.primaryToSecondaryGap)
secondaryActionRow.frame(height: Layout.secondaryHeight)
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
}
private var pendingAnswerSurface: some View {
VStack(spacing: 0) {
topBar.frame(height: Layout.topBarHeight)
ScrollView(.vertical) {
Text(state.aiSession.answer?.text ?? "")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
.scrollIndicators(.visible)
Button(action: state.confirmPendingAIAnswer) {
Image(systemName: "plus")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.frame(width: Layout.fieldActionWidth, height: 44)
.background(palette.accent, in: Capsule())
}
.buttonStyle(.plain)
.accessibilityIdentifier("assistant.pending.insert")
.frame(height: 55)
.accessibilityLabel(ExtL10n.text("keyboard.assistant.insertPending"))
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
}
private var resolvedHeight: CGFloat {
TypingSurfaceMetrics.contentHeight(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
)
}
// MARK: - Top bar
@ViewBuilder
private var topBar: some View {
if state.aiSession.canInsert {
cancelTopBar(
action: state.discardPendingAIAnswer,
labelKey: "keyboard.assistant.discardPending",
hintKey: "keyboard.assistant.discardPendingHint"
)
} else if state.canCancelAIInput {
cancelTopBar(
action: state.cancelAIInput,
labelKey: "keyboard.ai.cancel",
hintKey: "keyboard.ai.cancelHint"
)
} else if state.canCancelVoiceInput {
cancelTopBar(
action: state.cancelVoiceInput,
labelKey: "keyboard.voice.cancel",
hintKey: "keyboard.voice.cancelHint"
)
} else if shouldShowClipboardSuggestion {
ClipboardSuggestionBar(
text: state.clipboardSuggestionText ?? "",
onInsert: insertClipboardSuggestion,
onDismiss: dismissClipboardPresentation
)
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else if showsClipboardSkills {
cancelTopBar(
action: dismissClipboardPresentation,
labelKey: "keyboard.assistant.dismissClipboard",
hintKey: "keyboard.assistant.dismissClipboardHint"
)
} else {
ZStack {
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
HStack {
KeyboardBrandLogo(action: state.openSettings)
Spacer(minLength: 0)
}
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
}
private func cancelTopBar(
action: @escaping () -> Void,
labelKey: String,
hintKey: String
) -> some View {
HStack(spacing: Spacing.xs) {
KeyboardBrandLogo(action: state.openSettings)
Spacer(minLength: 0)
KeyboardCancelButton(
action: action,
accessibilityLabel: ExtL10n.text(labelKey),
accessibilityHint: ExtL10n.text(hintKey),
accessibilityIdentifier: cancelIdentifier(for: labelKey)
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
private func cancelIdentifier(for labelKey: String) -> String {
switch labelKey {
case "keyboard.assistant.dismissClipboard":
return "assistant.clipboard.dismiss"
case "keyboard.assistant.discardPending":
return "assistant.pending.discard"
default:
return "assistant.cancel"
}
}
// MARK: - Context
@ViewBuilder
private var contextArea: some View {
ZStack {
if showsOOBEClipboardSkill, activeStatus == nil {
clipboardSkillPager
} else if let onboardingPracticeHint, activeStatus == nil {
Text(onboardingPracticeHint)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.padding(.horizontal, 14)
.frame(height: Layout.hotwordHeight)
.background(palette.accentMuted, in: Capsule())
} else if let tip = state.skillTipText, !tip.isEmpty {
IntrinsicWidthCap(maxWidth: Layout.skillTipMaxWidth) {
Text(tip)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(2)
.multilineTextAlignment(.center)
.truncationMode(.tail)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.glassEffect(.regular, in: Capsule())
}
.accessibilityIdentifier("assistant.skillTip")
} else if showsClipboardSkills {
clipboardSkillPager
} else if let status = activeStatus {
statusText(status.text, color: status.color)
} else if showsLongPressCoach {
Text(ExtL10n.string("keyboard.assistant.longPressCoach"))
.font(.system(size: 13, weight: .medium))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.padding(.horizontal, 14)
.frame(height: Layout.hotwordHeight)
.overlay(Capsule().stroke(palette.dividerStrong, lineWidth: 1))
} else {
hintCarousel
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.md)
}
private func statusText(_ text: String, color: Color) -> some View {
Text(text)
.font(TypeStyle.body)
.foregroundStyle(color)
.lineLimit(2)
.multilineTextAlignment(.center)
.truncationMode(.tail)
.frame(maxWidth: .infinity)
}
private var activeStatus: (text: String, color: Color)? {
if let error = state.aiSession.errorMessage,
!error.isEmpty,
!ordinaryPipelineIsActive {
return (error, palette.warning)
}
switch state.aiSession.phase {
case .preparing:
return (ExtL10n.string("keyboard.placeholder.preparing"), palette.textSecondary)
case .listening:
return (
state.aiSession.transcript.isEmpty
? ExtL10n.string("keyboard.ai.listening")
: state.aiSession.transcript,
palette.textPrimary
)
case .recognizing:
return (
state.aiSession.transcript.isEmpty
? ExtL10n.string("keyboard.ai.recognizing")
: state.aiSession.transcript,
palette.textSecondary
)
case .generating:
return (aiGeneratingStatus, palette.textSecondary)
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
break
}
switch state.phase {
case .idle:
if !state.micDisabledHint.isEmpty {
return (state.micDisabledHint, palette.warning)
}
return nil
case .requestingPermissions:
return (
state.lastTranscript.isEmpty
? ExtL10n.string("keyboard.placeholder.preparing")
: state.lastTranscript,
palette.textSecondary
)
case .recording:
return (state.lastTranscript.isEmpty ? " " : state.lastTranscript, palette.textPrimary)
case .processing:
return (
state.lastTranscript.isEmpty
? ExtL10n.string("keyboard.placeholder.processing")
: state.lastTranscript,
palette.textSecondary
)
case .error(_, let message):
return (message ?? ExtL10n.string("keyboard.placeholder.error"), palette.warning)
case .denied(let reason):
let key = reason == .mic ? "keyboard.denied.mic" : "keyboard.denied.speech"
return (ExtL10n.string(key), palette.warning)
}
}
private var aiGeneratingStatus: String {
switch state.pendingClipboardSkillID {
case AIClipboardSkillCatalog.extractTodosID:
return ExtL10n.string("keyboard.ai.skill.extracting")
case AIClipboardSkillCatalog.extractEventsID:
return ExtL10n.string("keyboard.ai.skill.extractingEvents")
case AIClipboardSkillCatalog.navigateID:
return ExtL10n.string("keyboard.ai.skill.extractingAddress")
case AIClipboardSkillCatalog.saveToNotesID:
return ExtL10n.string("keyboard.ai.skill.namingNote")
default:
if !state.aiSession.transcript.isEmpty,
!AIClipboardPrompt.isInternalPrompt(state.aiSession.transcript) {
return state.aiSession.transcript
}
return ExtL10n.string("keyboard.ai.thinking")
}
}
private var hintCarousel: some View {
IntrinsicWidthCap(maxWidth: Layout.hotwordMaxWidth) {
Button {
guard let hint = currentHint else { return }
state.submitAIHint(hint)
} label: {
HStack(spacing: 6) {
if let hint = currentHint {
Image(systemName: hint.visualKind.systemImage)
.font(.system(size: 13, weight: .semibold))
}
Text(currentHint.map(\.resolvedDisplayText)
?? ExtL10n.string("keyboard.ai.placeholder"))
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
}
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity)
.frame(height: Layout.hotwordHeight)
.contentShape(Capsule())
.overlay(Capsule().stroke(palette.dividerStrong, lineWidth: 1))
}
.buttonStyle(.plain)
.contentShape(Capsule())
.disabled(currentHint == nil || !state.aiServiceAvailable || !assistantIsResting)
.accessibilityIdentifier("assistant.hint")
.accessibilityLabel(
Text(currentHint.map(\.resolvedDisplayText)
?? ExtL10n.string("keyboard.ai.placeholder"))
)
}
.opacity(hintOpacity)
.contentShape(Capsule())
}
// MARK: - Clipboard skills
private var clipboardSkillPager: some View {
GeometryReader { proxy in
let skillsPerPage = proxy.size.width < 352 ? 4 : 5
let pages = skillPages(from: visibleClipboardSkills, count: skillsPerPage)
VStack(spacing: 5) {
TabView(selection: $selectedSkillPage) {
ForEach(Array(pages.enumerated()), id: \.offset) { page, skills in
HStack(spacing: Layout.skillCellSpacing) {
ForEach(skills) { skill in
skillChip(skill)
}
}
.frame(maxWidth: .infinity)
.tag(page)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.accessibilityIdentifier("assistant.skills.pager")
HStack(spacing: Layout.pageDotSize) {
ForEach(pages.indices, id: \.self) { page in
Circle()
.fill(page == selectedSkillPage
? palette.textPrimary
: palette.textTertiary.opacity(0.45))
.frame(width: Layout.pageDotSize, height: Layout.pageDotSize)
}
}
.opacity(pages.count > 1 ? 1 : 0)
.accessibilityHidden(true)
}
.padding(.bottom, Layout.skillPaginationBottomInset)
}
}
private func skillPages(
from skills: [AIClipboardSkill],
count: Int
) -> [[AIClipboardSkill]] {
stride(from: 0, to: skills.count, by: count).map { start in
Array(skills[start ..< min(start + count, skills.count)])
}
}
private func skillChip(_ skill: AIClipboardSkill) -> some View {
Button {
state.submitAIClipboardSkill(skill)
} label: {
VStack(spacing: 6) {
Image(systemName: skill.systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.textPrimary.opacity(0.85))
.frame(width: Layout.skillButtonSize, height: Layout.skillButtonSize)
.glassEffect(.regular.interactive(), in: Circle())
Text(clipboardSkillTitle(skill))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
.frame(width: Layout.skillCellWidth)
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityIdentifier("assistant.skill.\(skill.id)")
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
if skill.id == AIClipboardSkillCatalog.translateID {
return AIClipboardSkillCatalog.translateButtonTitle(
translationTargetLocaleId: state.translationTargetLocaleId,
uiLanguage: state.uiLanguage
)
}
if let custom = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
!custom.isEmpty {
return custom
}
return ExtL10n.string(skill.titleKey)
}
private var showsClipboardSkills: Bool {
#if DEBUG
if let preview = Self.debugPreviewSkills,
!preview.isEmpty,
!debugSkillsDismissed {
return assistantIsResting
}
#endif
if showsOOBEClipboardSkill {
return assistantIsResting
}
guard assistantIsResting, state.clipboardHistoryEnabled else { return false }
guard !visibleClipboardSkills.isEmpty,
let newest = clipboardHistory.newestEntry,
newest.id != dismissedClipboardEntryID else {
return false
}
return AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: newest
)
}
private var visibleClipboardSkills: [AIClipboardSkill] {
#if DEBUG
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
return preview
}
#endif
if let expectedSkillID = oobeExpectedSkillID {
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
}
guard let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return []
}
return ClipboardSkillSemanticRanker.recommended(
skills: state.clipboardSkillCatalog,
sourceText: newest.text,
analysis: snapshot.analysis,
uiLanguage: state.uiLanguage,
limit: Layout.maximumSemanticSkills
)
}
private var showsOOBEClipboardSkill: Bool {
oobeExpectedSkillID != nil
}
private var oobeExpectedSkillID: String? {
switch state.oobePracticeSession?.expectedFeature {
case .clipboardTranslate:
return AIClipboardSkillCatalog.translateID
case .clipboardReply:
return AIClipboardSkillCatalog.replyID
case .voiceInput, .askAI, nil:
return nil
}
}
private var onboardingPracticeHint: String? {
switch state.oobePracticeSession?.expectedFeature {
case .voiceInput:
return ExtL10n.string("keyboard.onboarding.practice.mic")
case .clipboardTranslate:
return ExtL10n.string("keyboard.onboarding.practice.translate")
case .clipboardReply:
return ExtL10n.string("keyboard.onboarding.practice.reply")
case .askAI:
return ExtL10n.string("keyboard.onboarding.practice.askAI")
case nil:
return state.isOnboardingPracticeActive
? ExtL10n.string("keyboard.onboarding.practice.mic")
: nil
}
}
// MARK: - Primary actions
private var primaryActionRow: some View {
ZStack {
HStack {
primarySideButton(leading: true)
.offset(x: Layout.sideButtonEdgeInset)
Spacer(minLength: 0)
primarySideButton(leading: false)
.offset(x: -Layout.sideButtonEdgeInset)
}
.opacity(sideButtonsVisible ? 1 : 0)
.allowsHitTesting(sideButtonsVisible)
.accessibilityHidden(!sideButtonsVisible)
microphoneButton
}
.frame(maxWidth: Layout.actionClusterMaxWidth)
.frame(maxWidth: .infinity)
}
@ViewBuilder
private func primarySideButton(leading: Bool) -> some View {
let swapped = state.handednessPreference.swapsActionKeys
let showsSpace = leading ? swapped : !swapped
if showsSpace {
circleActionButton(
systemName: "space",
label: ExtL10n.string("keyboard.assistant.space"),
action: state.insertSpace
)
.accessibilityIdentifier("assistant.space")
} else {
repeatingDeleteButton
}
}
private var microphoneButton: some View {
ZStack {
Capsule()
.fill(.clear)
.glassEffect(
.regular.tint(microphoneTint).interactive(),
in: Capsule()
)
microphoneContent
}
.frame(width: primaryButtonWidth, height: primaryButtonHeight)
.contentShape(Capsule())
.scaleEffect(microphoneIsPressable ? 1 : 0.99)
.onLongPressGesture(
minimumDuration: 0.45,
maximumDistance: 120,
pressing: handleMicrophonePressing,
perform: beginAIMicrophone
)
// Processing is an explicit waiting state: the capsule stays visible
// for feedback but must not react to taps or Liquid Glass presses.
.allowsHitTesting(microphoneIsPressable)
.accessibilityElement()
.accessibilityAddTraits(.isButton)
.accessibilityIdentifier("assistant.mic.\(microphoneStateIdentifier)")
.accessibilityLabel(ExtL10n.text("keyboard.assistant.micA11y"))
.accessibilityHint(ExtL10n.text("keyboard.assistant.micHint"))
.accessibilityAction(named: ExtL10n.text("keyboard.assistant.dictationAction")) {
guard microphoneIsPressable else { return }
state.tapMic()
}
.accessibilityAction(named: ExtL10n.text("keyboard.assistant.aiAction")) {
guard assistantIsResting else { return }
state.tapAIMic()
}
.disabled(!microphoneIsPressable)
}
@ViewBuilder
private var microphoneContent: some View {
if state.assistantInsertionSucceeded {
Image(systemName: "checkmark")
.font(.system(size: 27, weight: .bold))
.foregroundStyle(.white)
} else {
switch state.aiSession.phase {
case .listening:
waveform(color: .white)
case .preparing, .recognizing, .generating:
ProgressView().tint(.white)
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
ordinaryMicrophoneContent
}
}
}
@ViewBuilder
private var ordinaryMicrophoneContent: some View {
switch state.phase {
case .recording:
waveform(color: Color(red: 1, green: 0.78, blue: 0.78))
case .requestingPermissions, .processing:
ProgressView().tint(.white)
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 25, weight: .semibold))
.foregroundStyle(.white)
case .idle, .denied:
Image(systemName: "mic.fill")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(.white)
.symbolEffect(.breathe, isActive: micIsHoldingForAI)
}
}
private func waveform(color: Color) -> some View {
WaveformView(
level: state.level,
barCount: 7,
color: color,
active: true
)
.frame(width: 38, height: 26)
.clipped()
}
private var microphoneTint: Color {
if state.assistantInsertionSucceeded {
return palette.accent
}
if micIsHoldingForAI {
return palette.aiTeal
}
switch state.aiSession.phase {
case .listening:
return palette.aiTeal
case .preparing, .recognizing, .generating:
// Match ordinary dictation's non-interactive waiting appearance,
// including hint-card requests that start directly in generation.
return palette.recordRed.opacity(0.72)
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
break
}
switch state.phase {
case .recording:
return palette.recordRed
case .requestingPermissions, .processing:
return palette.recordRed.opacity(0.72)
case .error, .denied:
return palette.warning
case .idle:
return palette.accent
}
}
private var microphoneStateIdentifier: String {
switch state.aiSession.phase {
case .preparing:
return "aiPreparing"
case .listening:
return "aiListening"
case .recognizing:
return "aiRecognizing"
case .generating:
return "aiGenerating"
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
break
}
switch state.phase {
case .requestingPermissions:
return "dictationPreparing"
case .recording:
return "dictationRecording"
case .processing:
return "dictationProcessing"
case .error:
return "error"
case .denied:
return "denied"
case .idle:
return state.assistantInsertionSucceeded ? "success" : "idle"
}
}
private var primaryButtonWidth: CGFloat {
state.layoutWidth > 0 && state.layoutWidth < 350
? Layout.compactPrimaryWidth
: Layout.primaryWidth
}
private var primaryButtonHeight: CGFloat {
state.layoutWidth > 0 && state.layoutWidth < 350
? Layout.compactPrimaryHeight
: Layout.primaryHeight
}
private func handleMicrophonePressing(_ pressing: Bool) {
if pressing {
micLongPressConsumed = false
guard microphoneIsPressable else { return }
state.setMicTouchActive(true)
if assistantIsResting {
withAnimation(.easeOut(duration: 0.12)) {
micIsHoldingForAI = true
}
}
return
}
state.setMicTouchActive(false)
withAnimation(.easeOut(duration: 0.12)) {
micIsHoldingForAI = false
}
if micLongPressConsumed {
micLongPressConsumed = false
return
}
handleMicrophoneTap()
}
private func beginAIMicrophone() {
guard assistantIsResting else { return }
micLongPressConsumed = true
showsLongPressCoach = false
KeyboardHapticFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
state.tapAIMic()
}
private func handleMicrophoneTap() {
guard microphoneIsPressable else { return }
if state.aiSession.phase == .listening {
state.tapAIMic()
} else {
state.tapMic()
}
}
private var microphoneIsPressable: Bool {
if state.aiSession.isBusy {
return state.aiSession.phase == .listening
}
switch state.phase {
case .processing, .requestingPermissions:
return false
case .idle, .recording, .error, .denied:
return !state.micDisabled
}
}
// MARK: - Secondary actions
private var secondaryActionRow: some View {
ZStack {
HStack(spacing: 0) {
lowerActionCluster(leading: true)
Spacer(minLength: 0)
lowerActionCluster(leading: false)
}
.opacity(sideButtonsVisible ? 1 : 0)
.allowsHitTesting(sideButtonsVisible)
.accessibilityHidden(!sideButtonsVisible)
fieldActionButton
}
.frame(maxWidth: Layout.actionClusterMaxWidth)
.frame(maxWidth: .infinity)
.opacity(activePipeline ? 0 : 1)
.allowsHitTesting(!activePipeline)
.accessibilityHidden(activePipeline)
}
@ViewBuilder
private func lowerActionCluster(leading: Bool) -> some View {
let button = lowerSideButton(leading: leading)
.offset(x: leading
? Layout.sideButtonEdgeInset
: -Layout.sideButtonEdgeInset)
if state.showsSystemGlobeKey {
if leading {
HStack(spacing: 8) {
SystemGlobeKey(
state: state,
width: compactIPadLayout ? 40 : 44,
height: lowerCircleSize
)
button
}
} else {
HStack(spacing: 8) {
button
Color.clear.frame(width: compactIPadLayout ? 40 : 44)
}
}
} else {
button
}
}
@ViewBuilder
private func lowerSideButton(leading: Bool) -> some View {
let swapped = state.handednessPreference.swapsActionKeys
let showsEdit = leading ? swapped : !swapped
if showsEdit {
circleActionButton(
systemName: "pencil.line",
label: ExtL10n.string("keyboard.edit.apply"),
disabled: !state.editAvailable,
onPressingChanged: state.setMicTouchActive,
action: state.beginEditLastInput
)
.accessibilityIdentifier("assistant.edit")
.opacity(state.editAvailable ? 1 : 0)
.accessibilityHidden(!state.editAvailable)
} else {
circleActionButton(
systemName: "arrow.uturn.backward",
label: ExtL10n.string("keyboard.undoA11y"),
disabled: !state.undoAvailable,
action: state.undoLastInsertion
)
.accessibilityIdentifier("assistant.undo")
.opacity(state.undoAvailable ? 1 : 0)
.accessibilityHidden(!state.undoAvailable)
}
}
private var fieldActionButton: some View {
Button(action: performFieldAction) {
Image(systemName: fieldActionSystemImage)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(fieldActionButtonForeground)
.frame(
width: fieldActionButtonWidth,
height: KeyboardChromeLayout.assistantActionCapsuleHeight
)
.background(fieldActionButtonFill, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
.disabled(!state.assistantActionAvailable)
.accessibilityIdentifier(
"assistant.action.\(state.returnKeyRole.assistantActionIdentifier)"
)
.accessibilityLabel(ExtL10n.text(state.returnKeyRole.titleKey))
}
private var fieldActionSystemImage: String {
fieldActionConfirmationVisible
? "checkmark"
: state.returnKeyRole.assistantActionSystemImage
}
private var fieldActionButtonFill: Color {
state.assistantActionAvailable
? NativeKeyboardKeyColors.fill(for: colorScheme)
: NativeKeyboardKeyColors.pressedFill(for: colorScheme)
}
private var fieldActionButtonForeground: Color {
state.assistantActionAvailable
? NativeKeyboardKeyColors.text(for: colorScheme)
: NativeKeyboardKeyColors.text(for: colorScheme).opacity(0.58)
}
private func performFieldAction() {
guard state.assistantActionAvailable else { return }
state.performAssistantFieldAction()
fieldActionConfirmationVisible = true
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(550))
fieldActionConfirmationVisible = false
}
}
private var fieldActionButtonWidth: CGFloat {
compactIPadLayout
? Layout.compactIPadFieldActionWidth
: Layout.fieldActionWidth
}
private var lowerCircleSize: CGFloat {
compactIPadLayout ? Layout.compactIPadCircleSize : Layout.circleSize
}
private var compactIPadLayout: Bool {
state.usesIPadLayoutMetrics && state.layoutWidth > 0 && state.layoutWidth < 420
}
private func circleActionButton(
systemName: String,
label: String,
disabled: Bool = false,
onPressingChanged: @escaping (Bool) -> Void = { _ in },
action: @escaping () -> Void
) -> some View {
RectangularToolbarButton(
systemName: systemName,
label: label,
disabled: disabled,
usesLiquidGlass: true,
usesCircleGlass: true,
hapticIntensity: state.keyboardHapticIntensity,
onPressingChanged: onPressingChanged,
action: action
)
.frame(width: lowerCircleSize, height: lowerCircleSize)
}
private var repeatingDeleteButton: some View {
RepeatingPressButton(
disabled: false,
hapticIntensity: state.keyboardHapticIntensity,
action: state.deleteBackward
) { isPressed in
ZStack {
Color.clear
Image(systemName: "delete.left")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
}
.frame(width: lowerCircleSize, height: lowerCircleSize)
.glassEffect(.regular.interactive(), in: Circle())
.scaleEffect(isPressed ? 0.97 : 1)
.animation(.easeOut(duration: 0.08), value: isPressed)
}
.accessibilityIdentifier("assistant.delete")
.accessibilityLabel(Text(ExtL10n.string("keyboard.assistant.delete")))
}
// MARK: - Visibility and clipboard
private var assistantIsResting: Bool {
guard !state.aiSession.isBusy, !state.aiSession.canInsert else { return false }
switch state.phase {
case .idle, .error, .denied:
return true
case .requestingPermissions, .recording, .processing:
return false
}
}
private var activePipeline: Bool {
if state.aiSession.isBusy { return true }
return ordinaryPipelineIsActive
}
private var ordinaryPipelineIsActive: Bool {
switch state.phase {
case .requestingPermissions, .recording, .processing:
return true
case .idle, .error, .denied:
return false
}
}
private var sideButtonsVisible: Bool {
assistantIsResting
}
private var shouldShowClipboardSuggestion: Bool {
guard state.canShowClipboardEntry else { return false }
guard let text = state.clipboardSuggestionText, !text.isEmpty else { return false }
return true
}
private func insertClipboardSuggestion() {
dismissedClipboardEntryID = clipboardHistory.newestEntry?.id
if let text = state.clipboardSuggestionText {
state.insertClipboardText(text)
}
}
private func dismissClipboardPresentation() {
#if DEBUG
if Self.debugPreviewSkills != nil {
debugSkillsDismissed = true
}
#endif
dismissedClipboardEntryID = clipboardHistory.newestEntry?.id
state.dismissClipboardSuggestion()
}
// MARK: - Carousel
private func resetCarousel() {
reloadHintPool(resetBag: true)
guard !showsClipboardSkills else { return }
showNextHint(animated: false)
}
private func reloadHintPool(resetBag: Bool) {
let locale = AIHintLocaleResolver.packLocale()
let pack = AIHintStore.resolvedPack(locale: locale)
poolCards = AIHintPool.activeCards(pack: pack)
if resetBag {
carouselBag.reset()
}
}
private func showNextHint(animated: Bool) {
guard let next = carouselBag.next(from: poolCards) else {
currentHint = nil
return
}
if animated, !reduceMotion {
withAnimation(.easeOut(duration: 0.18)) { hintOpacity = 0 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
currentHint = next
withAnimation(.easeIn(duration: 0.18)) { hintOpacity = 1 }
}
} else {
currentHint = next
hintOpacity = 1
}
}
}
/// Uses a subview's intrinsic width until it reaches a visual cap. Unlike
/// `frame(maxWidth:)`, this layout does not expand short labels to the cap,
/// so each capsule and its complete hit target follow the actual content.
private struct IntrinsicWidthCap: Layout {
let maxWidth: CGFloat
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
guard let subview = subviews.first else { return .zero }
let ideal = subview.sizeThatFits(.unspecified)
let availableWidth = min(proposal.width ?? maxWidth, maxWidth)
let width = min(ideal.width, availableWidth)
let fitted = subview.sizeThatFits(
ProposedViewSize(width: width, height: proposal.height)
)
return CGSize(width: width, height: fitted.height)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
guard let subview = subviews.first else { return }
subview.place(
at: CGPoint(x: bounds.midX, y: bounds.midY),
anchor: .center,
proposal: ProposedViewSize(width: bounds.width, height: bounds.height)
)
}
}