Files
OSGKeyboard/OSGKeyboardExt/Views/AIKeyboardView.swift
T
rockyandClaude Opus 5 0023741455 feat(app): add crash visibility, memory relief, and clipboard reply work
Squashes the working tree that had accumulated on cloud-agent. Two
strands of work, kept in one commit at the maintainer's request.

Crash and stability (this session):

- Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics
  are recorded on device. Keyboard-extension memory kills are the most
  likely way a user loses the keyboard mid-sentence and iOS does not
  classify them as crashes, so they never reach App Store Connect.
- Upload those reports to POST /v1/analytics/diagnostics from internal
  builds only (local Debug and TestFlight), reusing the analytics
  installation identifier and skipping entirely when analytics are off.
  App Store builds keep everything on device.
- Shed the Chinese engine, English lexicon, and in-flight pipeline work
  at the 40 MiB and 48 MiB budget thresholds instead of waiting for a
  system memory warning that jetsam often preempts.
- Install audio taps with the input node's live format through one
  shared adaptive resampler, so an audio-route change mid-recording no
  longer raises an uncatchable format-mismatch exception. Covers the
  dictation preview and the Mac recorder, which still used the pattern
  the Flow capture path had already moved away from.
- Harden the librime bridge: never pass a nil string across a nonnull
  boundary when a user dictionary holds malformed UTF-8, null-check the
  engine API vector, and distrust a candidate count without its array.
- Guard the expanded candidate grid against a list that shrinks while
  the collection view is still laying out against the previous count.
- Resolve localized text without the App Group so the screen shown when
  that container is missing renders its message instead of crashing on
  launch, which is the one situation it exists to handle.
- Gate CI on the App Group and Keychain wiring that the keyboard
  extension hard-depends on, with a self-test proving the check fails.

Clipboard, reply, and semantics (pre-existing working tree):

- Unify the reply center, refresh keyboard AI features, and add native
  event and reminder exporters.
- Expand clipboard semantic routing with authored corpora and a
  product-blind holdout, plus the scripts that build and validate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:31:29 -07:00

1532 lines
57 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
}
private struct SemanticBadgeContent {
let intentKey: String?
let domainKey: String?
var text: String {
[intentKey, domainKey]
.compactMap { $0 }
.map { ExtL10n.string($0) }
.joined(separator: " · ")
}
}
#if DEBUG
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
static var debugPreviewSkills: [AIClipboardSkill]?
/// Deterministic intent/domain labels for the assistant UI harness.
static var debugPreviewSemanticBadgeKeys: (intent: String?, domain: String?)?
/// 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 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.canSelectReplyVariant {
replyVariantsSurface
} 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
state.dismissedClipboardSkillEntryID = 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)
if state.autoResultReadOnly {
// Auto-translate / email-reply stay staged: the result is shown,
// and lands in the field via the labeled liquid-glass button.
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) {
HStack(spacing: 6) {
Image(systemName: "arrow.down.to.line")
.font(TypeStyle.body.weight(.semibold))
ExtL10n.text(state.autoResultInsertLabelKey)
.font(TypeStyle.body.weight(.semibold))
}
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, 22)
.frame(height: 44)
.glassEffect(.regular.interactive(), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityIdentifier("assistant.autoResult.insert")
.frame(height: 55)
.accessibilityLabel(ExtL10n.text(state.autoResultInsertLabelKey))
} else {
// A single reply is one tappable liquid-glass card — same style
// as the multi-variant cards; tapping it inserts.
ScrollView(.vertical) {
pendingAnswerCard
.padding(.horizontal, Spacing.xs)
.padding(.vertical, Spacing.xs)
}
.scrollIndicators(.visible)
.scrollBounceBehavior(.basedOnSize)
}
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
}
private var pendingAnswerCard: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium)
return Button(action: state.confirmPendingAIAnswer) {
HStack(alignment: .top, spacing: Spacing.sm) {
Text(state.aiSession.answer?.text ?? "")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "arrow.down.to.line")
.font(TypeStyle.body.weight(.semibold))
.foregroundStyle(palette.textSecondary)
.accessibilityHidden(true)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(shape)
.glassEffect(
.regular
.tint(palette.textPrimary.opacity(0.04))
.interactive(),
in: shape
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("assistant.pending.insert")
.accessibilityLabel(Text(state.aiSession.answer?.text ?? ""))
.accessibilityHint(ExtL10n.text("keyboard.assistant.insertPending"))
}
private var replyVariantsSurface: some View {
VStack(spacing: 0) {
topBar.frame(height: Layout.topBarHeight)
ScrollView(.vertical) {
LazyVStack(spacing: Spacing.xs) {
ForEach(state.aiSession.replyVariants) { variant in
replyVariantButton(variant)
}
}
.padding(.horizontal, Spacing.xs)
.padding(.vertical, Spacing.xs)
}
.scrollIndicators(.visible)
.scrollBounceBehavior(.basedOnSize)
.accessibilityIdentifier("assistant.replyVariants")
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
}
private func replyVariantButton(_ variant: AIReplyVariant) -> some View {
let title = ExtL10n.string(variant.kind.titleKey)
let shape = RoundedRectangle(cornerRadius: Radius.medium)
return Button {
state.selectAIReplyVariant(variant.id)
} label: {
HStack(alignment: .top, spacing: Spacing.sm) {
Image(
systemName: variant.kind.usesEmotionIcon
? variant.emotion.systemImage(fallback: variant.kind)
: variant.kind.systemImage
)
.resizable()
.scaledToFit()
.symbolRenderingMode(.monochrome)
.foregroundStyle(palette.textPrimary)
.frame(width: 17, height: 17)
.frame(width: 24, height: 24)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
Text(variant.text)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(shape)
.glassEffect(
.regular
.tint(palette.textPrimary.opacity(0.04))
.interactive(),
in: shape
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("assistant.replyVariant.\(variant.kind.rawValue)")
.accessibilityLabel(Text("\(title), \(variant.text)"))
.accessibilityHint(ExtL10n.text("keyboard.ai.replyVariant.insertHint"))
}
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 || state.aiSession.canSelectReplyVariant {
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 showsClipboardSkillBar {
// Fresh copy, no AI result yet: the top bar becomes the skill entry
// point (paste + every applicable skill + dismiss).
clipboardSkillTopBar
} else if showsClipboardSkills {
// OOBE / debug chip pager keeps its dismiss bar.
cancelTopBar(
action: dismissClipboardPresentation,
labelKey: "keyboard.assistant.dismissClipboard",
hintKey: "keyboard.assistant.dismissClipboardHint"
)
} else {
ZStack {
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
HStack {
leadingBrandOrPaste
Spacer(minLength: 0)
}
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
}
/// Fresh-copy top bar (no active result): paste + scrollable skills + dismiss.
private var clipboardSkillTopBar: some View {
HStack(spacing: 6) {
leadingScrollRow
KeyboardCancelButton(
action: dismissClipboardPresentation,
accessibilityLabel: ExtL10n.text("keyboard.assistant.dismissClipboard"),
accessibilityHint: ExtL10n.text("keyboard.assistant.dismissClipboardHint"),
accessibilityIdentifier: "assistant.clipboard.dismiss"
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
/// Fresh copy with applicable skills and no active AI result to defer to.
private var showsClipboardSkillBar: Bool {
guard assistantIsResting,
state.oobePracticeSession == nil,
state.clipboardHistoryEnabled,
state.aiServiceAvailable,
!state.aiSession.canInsert,
!state.aiSession.canSelectReplyVariant,
let newest = clipboardHistory.newestEntry,
newest.id != state.dismissedClipboardSkillEntryID,
!topBarSkills.isEmpty else {
return false
}
return AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: newest
)
}
/// The top-bar leading slot: a "Paste" glass capsule whenever a fresh copy is
/// available (replacing the OSG logo), otherwise the logo itself. Copying is
/// frequent, so this keeps a one-tap paste always in the same spot without a
/// full-width suggestion strip.
@ViewBuilder
private var leadingBrandOrPaste: some View {
if shouldShowClipboardSuggestion {
pasteCapsule
} else {
KeyboardBrandLogo(action: state.openSettings)
}
}
private var pasteCapsule: some View {
Button(action: insertClipboardSuggestion) {
Text(ExtL10n.string("keyboard.clipboard.paste"))
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.padding(.horizontal, 14)
.frame(height: 30)
.glassEffect(.regular.interactive(), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityIdentifier("assistant.clipboard.paste")
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.paste"))
}
private func cancelTopBar(
action: @escaping () -> Void,
labelKey: String,
hintKey: String
) -> some View {
HStack(spacing: 6) {
// Paste + every applicable skill scroll together; the X stays put.
leadingScrollRow
KeyboardCancelButton(
action: action,
accessibilityLabel: ExtL10n.text(labelKey),
accessibilityHint: ExtL10n.text(hintKey),
accessibilityIdentifier: cancelIdentifier(for: labelKey)
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
/// Leading region of the clipboard top bars: the OSG logo when there's no
/// fresh copy, otherwise a horizontally scrollable row whose first item is the
/// Paste capsule followed by every applicable skill — so Paste scrolls along
/// with the skills instead of staying pinned.
@ViewBuilder
private var leadingScrollRow: some View {
let paste = shouldShowClipboardSuggestion
let skills = topBarSkills
HStack(spacing: 6) {
if !paste {
KeyboardBrandLogo(action: state.openSettings)
}
if paste || !skills.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
if paste {
pasteCapsule
}
ForEach(skills) { skill in
otherSkillCapsule(skill)
}
}
.padding(.vertical, 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollClipDisabled()
} else {
Spacer(minLength: 0)
}
}
}
private func otherSkillCapsule(_ skill: AIClipboardSkill) -> some View {
Button {
state.submitAIClipboardSkill(skill, replyScene(for: skill))
} label: {
Text(clipboardSkillTitle(skill))
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.padding(.horizontal, 12)
.frame(height: 30)
.glassEffect(.regular.interactive(), in: Capsule())
}
.buttonStyle(.plain)
.disabled(!state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityIdentifier("assistant.otherSkill.\(skill.id)")
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
/// Skills that apply to the current copy, most-relevant first. This is the
/// sole skill entry point now that the circular chips are gone; it shows with
/// or without an active result (the invoked skill is excluded when active).
///
/// Purely relevance-ranked: a skill appears only when its signal is present
/// (a URL → Open / Summarize page; a phone → Call; a date/list → Events /
/// Todos / Organize; long text → Summary; plus Reply as the message baseline).
/// No blanket fallback, so Summary / Organize / Events / Notes do NOT show on
/// an ordinary short message.
private var topBarSkills: [AIClipboardSkill] {
guard state.oobePracticeSession == nil,
state.aiServiceAvailable,
let newest = clipboardHistory.newestEntry else {
return []
}
let translatable = clipboardIsTranslatable
let activeID = state.activeClipboardSkillID
func present(_ list: [AIClipboardSkill]) -> [AIClipboardSkill] {
list.filter {
$0.id != activeID
// Don't offer "Translate" for text already in the user's language.
&& ($0.id != AIClipboardSkillCatalog.translateID || translatable)
}
}
guard let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
// Analysis not ready yet — keep Reply reachable in the meantime.
let reply = state.enabledClipboardSkills.first {
$0.id == AIClipboardSkillCatalog.replyID
}
return present([reply].compactMap { $0 })
}
return present(
ClipboardSkillSemanticRanker.recommended(
skills: state.enabledClipboardSkills,
sourceText: newest.text,
analysis: snapshot.analysis,
uiLanguage: state.uiLanguage,
limit: 6
)
)
}
/// True only when the current copy is in a language other than the user's,
/// so offering Translate makes sense.
private var clipboardIsTranslatable: Bool {
guard let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return false
}
return ClipboardSkillSemanticRanker.isForeignLanguage(snapshot.analysis)
}
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(TypeStyle.footnote.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 {
VStack(spacing: Spacing.xs) {
if let content = semanticBadgeContent {
semanticBadge(content)
}
clipboardSkillPager
}
} else if let content = semanticBadgeContent {
semanticBadge(content)
} else if let status = activeStatus {
statusText(status.text, color: status.color)
} else if showsLongPressCoach {
Text(ExtL10n.string("keyboard.assistant.longPressCoach"))
.font(TypeStyle.footnote.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 semanticBadge(_ content: SemanticBadgeContent) -> some View {
HStack(spacing: 5) {
Image(systemName: "tag.fill")
.font(.system(size: 10, weight: .semibold))
.accessibilityHidden(true)
Text(content.text)
.font(TypeStyle.caption.weight(.semibold))
.lineLimit(1)
}
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, 10)
.frame(height: 24)
.background(palette.accentMuted, in: Capsule())
.accessibilityElement(children: .combine)
.accessibilityIdentifier("assistant.semantic.badge")
.accessibilityLabel(Text(content.text))
}
private var semanticBadgeContent: SemanticBadgeContent? {
#if DEBUG
if let keys = Self.debugPreviewSemanticBadgeKeys {
return SemanticBadgeContent(
intentKey: keys.intent,
domainKey: keys.domain
)
}
#endif
// Retired alongside the idle skill chips it accompanied: the copy flow now
// goes straight to an auto result, so no standalone badge is shown.
return nil
}
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(TypeStyle.footnote.weight(.semibold))
}
Text(currentHint.map(\.resolvedDisplayText)
?? ExtL10n.string("keyboard.ai.placeholder"))
.font(TypeStyle.footnote.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, replyScene(for: 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 replyScene(for skill: AIClipboardSkill) -> AIClipboardReplyScene? {
guard skill.id == AIClipboardSkillCatalog.replyID,
state.oobePracticeSession == nil,
let newest = clipboardHistory.newestEntry,
let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return nil
}
return AIClipboardReplyScene.resolve(
from: snapshot.analysis,
sourceText: newest.text
)
}
private func clipboardSkillTitle(_ skill: AIClipboardSkill) -> String {
// Capsules stay short: use the plain skill title, not the verbose
// "Translate to <language>" form.
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
}
// The idle circular skill chips are retired: auto mode runs one skill and
// the result panel's top-bar capsules offer the rest. Only OOBE / debug
// still present the chip pager above.
return false
}
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 else { return [] }
guard let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else {
return state.clipboardSkillCatalog.filter {
$0.id == AIClipboardSkillCatalog.replyID
}
}
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(OSGColor.fixedLightContent)
} else {
switch state.aiSession.phase {
case .listening:
waveform(color: OSGColor.fixedLightContent)
case .preparing, .recognizing, .generating:
ProgressView().tint(OSGColor.fixedLightContent)
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
ordinaryMicrophoneContent
}
}
}
@ViewBuilder
private var ordinaryMicrophoneContent: some View {
switch state.phase {
case .recording:
waveform(color: OSGColor.recordingWaveform)
case .requestingPermissions, .processing:
ProgressView().tint(OSGColor.fixedLightContent)
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 25, weight: .semibold))
.foregroundStyle(OSGColor.fixedLightContent)
case .idle, .denied:
Image(systemName: "mic.fill")
.font(TypeStyle.title.weight(.semibold))
.foregroundStyle(OSGColor.fixedLightContent)
.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(TypeStyle.title3)
.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(TypeStyle.headline)
.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,
!state.aiSession.canSelectReplyVariant 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() {
state.dismissedClipboardSkillEntryID = clipboardHistory.newestEntry?.id
if let text = state.clipboardSuggestionText {
state.insertClipboardText(text)
}
}
private func dismissClipboardPresentation() {
#if DEBUG
if Self.debugPreviewSkills != nil {
debugSkillsDismissed = true
}
#endif
state.dismissedClipboardSkillEntryID = clipboardHistory.newestEntry?.id
state.dismissClipboardSuggestion()
}
// MARK: - Carousel
private func resetCarousel() {
reloadHintPool(resetBag: true)
guard !showsClipboardSkills, semanticBadgeContent == nil 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)
)
}
}