Files
OSGKeyboard/OSGKeyboardExt/Typing/TypingRootView.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

849 lines
34 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TypingRootView.swift
// OSGKeyboard · Keyboard Extension
//
// Typing surface: candidate bar + QWERTY / 123 / symbols.
// Top-leading control returns to the voice surface.
import OSGKeyboardShared
import SwiftUI
enum TypingLayoutMetrics {
// Size decisions live in `TypingSurfaceMetrics` (Shared) so the UIKit
// height constraint and this SwiftUI grid cannot disagree.
static let outerPaddingTop: CGFloat = TypingSurfaceMetrics.outerPaddingTop
static let outerPaddingBottom: CGFloat = TypingSurfaceMetrics.outerPaddingBottom
static let topRegionHeight: CGFloat = TypingSurfaceMetrics.topRegionHeight
static let verticalKeySpacing: CGFloat = TypingSurfaceMetrics.verticalKeySpacing
static let keyCornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
/// Shared top row + three 50 pt key rows + native spacing + bottom row.
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
/// Collapsed candidate strip: keep this small so ScrollView doesn't fight ▼.
static let collapsedBarCandidateLimit = 10
/// Trailing ▼/▲ control (visual chip + hit pad); lives in an HStack, not over text.
static let expandChevronHitWidth: CGFloat = 44
static let expandChevronVisualSize: CGFloat = 34
static let expandGridColumns = 5
static let expandCellHeight: CGFloat = 42
// MARK: - iPad (regular size class) metrics
static func metrics(isIPad: Bool, width: CGFloat) -> TypingKeyLayoutBuilder.Metrics {
TypingSurfaceMetrics.metrics(isIPad: isIPad, width: width)
}
static func contentHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
}
}
struct TypingRootView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
var onInsert: (String) -> Void
var onDeleteBackward: () -> Void
/// After the first expand, keep the panel tree mounted and only toggle
/// opacity so subsequent ▼/▲ taps stay cheap.
@State private var candidatePanelMounted = false
/// Keys currently under a finger (grid-level touch pad, multi-touch).
@State private var highlightedKeyIDs: Set<String> = []
static func totalHeight(isIPad: Bool = false, width: CGFloat = 0) -> CGFloat {
TypingLayoutMetrics.contentHeight(isIPad: isIPad, width: width)
}
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
var body: some View {
let expanded = typing.isCandidatePanelExpanded
VStack(spacing: 0) {
topRegion
.frame(height: TypingLayoutMetrics.topRegionHeight)
// Keep the QWERTY tree mounted; only toggle visibility so ▼/▲
// does not destroy GeometryReader key rows every time.
ZStack(alignment: .top) {
typingKeySurface
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
.opacity(expanded ? 0 : 1)
.allowsHitTesting(!expanded && canAcceptTypingKeys)
.accessibilityHidden(expanded || !canAcceptTypingKeys)
if expanded || candidatePanelMounted {
expandedCandidatePanel
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
.accessibilityHidden(!expanded)
.transition(.identity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.top, TypingLayoutMetrics.outerPaddingTop)
.padding(.bottom, TypingLayoutMetrics.outerPaddingBottom)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
// No content-width cap: a key grid has to span the host width or the
// user's muscle memory for the system keyboard's absolute key
// positions is wrong on every key.
.frame(maxWidth: .infinity)
.frame(
height: Self.totalHeight(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
)
)
.background(Color.clear)
.environment(\.themePalette, palette)
// enterTypingMode is owned by KeyboardViewController.viewWillAppear
// when surface == .typing — avoid a duplicate prepare here.
.onChange(of: typing.isCandidatePanelExpanded) { _, isExpanded in
if isExpanded { candidatePanelMounted = true }
}
.onChange(of: hasCandidateContent) { _, hasContent in
if !hasContent { candidatePanelMounted = false }
}
.onAppear {
// Primary warm-up lives in KVC.viewWillAppear (covers app switch).
// Keep this for first mount / surface flip into typing.
KeyboardHapticFeedback.prepare()
}
}
// MARK: - Shared top region
@ViewBuilder
private var topRegion: some View {
if hasCandidateContent {
// Composing Chinese/English candidates hide the clipboard strip.
candidateBar
} else if state.canShowClipboardEntry,
let suggestion = state.clipboardSuggestionText,
!suggestion.isEmpty {
// A fresh copy: a "Paste" glass capsule (matching the assistant
// surface) replaces the input tabs until inserted or dismissed.
HStack(spacing: 6) {
Button {
state.insertClipboardText(suggestion)
} label: {
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("typing.clipboard.paste")
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.paste"))
Spacer(minLength: 0)
KeyboardCancelButton(
action: state.dismissClipboardSuggestion,
accessibilityLabel: ExtL10n.text("keyboard.assistant.dismissClipboard"),
accessibilityHint: ExtL10n.text("keyboard.assistant.dismissClipboardHint"),
accessibilityIdentifier: "typing.clipboard.dismiss"
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else {
idleTopBar
}
}
private var hasCandidateContent: Bool {
!typing.composition.preedit.isEmpty || !typing.composition.candidates.isEmpty
}
private var isChineseEnginePreparing: Bool {
typing.language == .chinese && typing.isPreparingEngine
}
private var canAcceptTypingKeys: Bool {
typing.language == .english || typing.engineReady
}
private var idleTopBar: some View {
ZStack {
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
HStack(spacing: Spacing.xs) {
KeyboardBrandLogo(action: state.openSettings)
// Globe key now lives at the bottom-left of the keyboard (matching
// iOS system layout); see the typingKeySurface ForEach.
if let err = typing.lastError {
typingErrorLabel(err)
} else if isChineseEnginePreparing {
ProgressView()
.controlSize(.small)
.accessibilityLabel(ExtL10n.text("keyboard.typing.preparingA11y"))
}
// iOS-style editing cluster (undo / redo / copy / cut) — iPad only,
// where the top bar has room to mirror the system shortcut row.
if state.usesIPadLayoutMetrics {
editingToolbar
}
Spacer(minLength: 0)
}
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
/// Rime failures that only host-side deployment can fix become a tappable
/// jump into the app; everything else stays a plain read-only notice.
@ViewBuilder
private func typingErrorLabel(_ message: String) -> some View {
if typing.lastErrorNeedsHostDeployment {
Button(action: state.openInputMethodSetup) {
HStack(spacing: 2) {
Text(message)
.font(TypeStyle.caption2.weight(.regular))
.lineLimit(1)
Image(systemName: "arrow.up.right")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(palette.danger)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.typing.setupA11yHint"))
} else {
Text(message)
.font(TypeStyle.caption2.weight(.regular))
.foregroundStyle(palette.danger)
.lineLimit(1)
}
}
// MARK: - Candidates
private var candidateBar: some View {
// HStack (not overlay / safeAreaInset): ▼ never paints over candidate text.
// Globe key now lives at the bottom-left of the keyboard (matching
// iOS system layout); see the typingKeySurface ForEach.
HStack(spacing: 0) {
if state.usesIPadLayoutMetrics {
editingToolbar
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
}
if typing.language == .english {
englishQuickTypeBar
} else {
chineseCandidateStrip
}
if typing.canExpandCandidatePanel {
expandChevronButton
} else {
Color.clear.frame(width: KeyboardTopBarMetrics.nestedHorizontalInset)
}
}
}
/// Three equal QuickType slots. Space applies only `role == .correction`.
private var englishQuickTypeBar: some View {
HStack(spacing: 0) {
ForEach(
Array(
typing.composition.candidates
.prefix(EnglishSuggestionEngine.slotCount)
.enumerated()
),
id: \.element.id
) { index, candidate in
if index > 0 {
Rectangle()
.fill(palette.dividerStrong)
.frame(width: 1, height: 18)
}
englishQuickTypeSlot(candidate)
}
}
.frame(maxWidth: .infinity)
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
}
private func englishQuickTypeSlot(_ candidate: TypingCandidate) -> some View {
let label = candidate.isQuoted ? "\"\(candidate.text)\"" : candidate.text
let weight: Font.Weight = candidate.role == .correction ? .semibold : .regular
let candidateRevision = typing.candidateRevision
return Text(label)
.font(.system(size: 17, weight: weight))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity, minHeight: 40)
.contentShape(Rectangle())
.onTapGesture {
apply(
typing.selectCandidate(
id: candidate.id,
candidateRevision: candidateRevision
)
)
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(candidate.text)
}
private var chineseCandidateStrip: some View {
let candidateRevision = typing.candidateRevision
return ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Spacing.xs) {
if typing.composition.candidates.isEmpty {
selectedCandidateLabel(text: typing.composition.preedit)
} else if typing.isCandidatePanelExpanded,
let candidate = typing.composition.candidates.first {
candidateChip(text: candidate.text) {
apply(
typing.selectCandidate(
id: candidate.id,
candidateRevision: candidateRevision
)
)
}
} else {
ForEach(
Array(
typing.composition.candidates
.prefix(TypingLayoutMetrics.collapsedBarCandidateLimit)
.enumerated()
),
id: \.element.id
) { index, candidate in
if index == 0 {
candidateChip(text: candidate.text) {
apply(
typing.selectCandidate(
id: candidate.id,
candidateRevision: candidateRevision
)
)
}
} else {
Text(candidate.text)
.font(TypeStyle.title3.weight(.regular))
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, 10)
.frame(height: 40)
.contentShape(Rectangle())
.onTapGesture {
apply(
typing.selectCandidate(
id: candidate.id,
candidateRevision: candidateRevision
)
)
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(candidate.text)
}
}
}
}
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
.padding(.trailing, Spacing.xs)
}
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
}
/// Opaque chip like the translation control so ▼ never shares pixels with text.
private var expandChevronButton: some View {
Button {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
typing.toggleCandidatePanelExpanded()
}
} label: {
Image(systemName: typing.isCandidatePanelExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(
width: TypingLayoutMetrics.expandChevronVisualSize,
height: TypingLayoutMetrics.expandChevronVisualSize
)
.background(expandChevronFill, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
.frame(
width: TypingLayoutMetrics.expandChevronHitWidth,
height: KeyboardTopBarMetrics.height
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.trailing, KeyboardTopBarMetrics.nestedHorizontalInset)
.accessibilityLabel(
ExtL10n.string(
typing.isCandidatePanelExpanded
? "keyboard.typing.candidates.collapseA11y"
: "keyboard.typing.candidates.expandA11y"
)
)
}
private var expandChevronFill: Color {
colorScheme == .dark
? OSGColor.keyboardDarkInputFill
: OSGColor.fixedLightContent
}
// MARK: - Editing toolbar (iPad)
/// iOS-style editing cluster: undo / redo / copy / cut. Mirrors the system
/// keyboard's shortcut row; surfaced only on iPad where the top bar fits.
@ViewBuilder
private var editingToolbar: some View {
HStack(spacing: 2) {
editingToolbarButton(
systemName: "arrow.uturn.backward",
label: ExtL10n.string("keyboard.undoA11y"),
enabled: state.undoAvailable
) { state.undoLastInsertion() }
editingToolbarButton(
systemName: "arrow.uturn.forward",
label: ExtL10n.string("keyboard.redoA11y"),
enabled: state.redoAvailable
) { state.redoLastInsertion() }
editingToolbarButton(
systemName: "doc.on.doc",
label: ExtL10n.string("keyboard.copyA11y"),
enabled: state.copyAvailable
) { state.copySelection() }
editingToolbarButton(
systemName: "scissors",
label: ExtL10n.string("keyboard.cutA11y"),
enabled: state.cutAvailable
) { state.cutSelection() }
}
}
private func editingToolbarButton(
systemName: String,
label: String,
enabled: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(TypeStyle.bodyEmph)
.foregroundStyle(enabled ? palette.textSecondary : palette.textTertiary)
.frame(width: 34, height: 34)
.background(enabled ? editingToolbarButtonFill : .clear, in: Circle())
}
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityLabel(Text(label))
}
private var editingToolbarButtonFill: Color {
colorScheme == .dark
? OSGColor.keyboardDarkInputFill
: OSGColor.fixedLightContent
}
/// UIKit-recycled labels — no SwiftUI Button per candidate.
private var expandedCandidatePanel: some View {
let candidateRevision = typing.candidateRevision
return CandidateExpandGridView(
candidates: typing.composition.candidates,
textColor: UIColor(keyTextColor),
dividerColor: UIColor(palette.dividerStrong),
onSelect: { candidate in
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
apply(
typing.selectCandidate(
id: candidate.id,
candidateRevision: candidateRevision
)
)
}
}
)
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
private func candidateChip(text: String, action: @escaping () -> Void) -> some View {
selectedCandidateLabel(text: text)
.contentShape(Rectangle())
.onTapGesture(perform: action)
.accessibilityAddTraits(.isButton)
.accessibilityLabel(text)
}
private func selectedCandidateLabel(text: String) -> some View {
VStack(spacing: 0) {
Text(text)
.font(.system(size: 19, weight: .medium))
.lineLimit(1)
if !typing.composition.preedit.isEmpty {
Text(typing.composition.preedit)
.font(.system(size: 9, weight: .regular, design: .monospaced))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
}
.foregroundStyle(keyTextColor)
.padding(.horizontal, 12)
.frame(minWidth: 56, minHeight: 40)
.background(
selectedCandidateFill,
in: RoundedRectangle(cornerRadius: 9, style: .continuous)
)
}
// MARK: - Keys
/// Visual keys + grid-level touch pad (Phase 1 gap fill + Phase 2
/// down → move → up). Geometry is shared via ``TypingKeyLayoutBuilder``.
private var typingKeySurface: some View {
GeometryReader { proxy in
let layout = makeTypingKeyLayout(size: proxy.size)
ZStack(alignment: .topLeading) {
TypingKeyTouchPad(
layout: layout,
hapticIntensity: state.keyboardHapticIntensity,
onHighlightChange: { highlightedKeyIDs = $0 },
onCommit: { commitTypingKey($0) },
onDeleteFire: { apply(typing.handleKey("⌫")) },
onShiftBegan: { typing.beginShiftHold() },
onShiftEnded: { typing.endShiftHold() }
)
ForEach(layout.keys) { key in
if key.id == TypingKeyLayoutBuilder.BottomKeyID.globe.rawValue {
// Globe key: SystemGlobeKey's UIButton handles its own
// tap (advance) / long-press (system input-mode list),
// so leave hit testing enabled here. The touch pad sits
// underneath but the UIButton intercepts touches in
// this frame, so hit testing against `layout.keys`
// never fires for the globe slot.
SystemGlobeKey(
state: state,
width: key.visualFrame.width,
height: key.visualFrame.height
)
.frame(width: key.visualFrame.width, height: key.visualFrame.height)
.position(
x: key.visualFrame.midX,
y: key.visualFrame.midY
)
} else {
visualTypingKey(key)
.frame(width: key.visualFrame.width, height: key.visualFrame.height)
.position(
x: key.visualFrame.midX,
y: key.visualFrame.midY
)
// Touches go to the UIKit pad; visuals stay for VoiceOver.
.allowsHitTesting(false)
}
}
}
}
}
private func makeTypingKeyLayout(size: CGSize) -> TypingKeyLayout {
let isIPad = state.usesIPadLayoutMetrics
// Select metrics from the same width the controller used to size the
// keyboard. Using `size` here would compare the grid's own width to
// its height (always landscape) and could pick a different bucket than
// the height constraint, clipping the bottom row.
let metrics = TypingLayoutMetrics.metrics(isIPad: isIPad, width: state.layoutWidth)
let pageLabel = typing.page == .letters ? "123" : "ABC"
let spaceLabel = typing.language == .chinese ? "空格" : "space"
let returnLabel: String = {
if state.returnKeyRole.usesActionFill {
return ExtL10n.string(state.returnKeyRole.titleKey)
}
return "return"
}()
// iPad spends its extra width on comma / period like the system
// keyboard, instead of stretching the space bar across it.
let punctuationKeys: TypingKeyLayoutBuilder.PunctuationKeys? = isIPad
? (typing.language == .chinese
? .init(comma: ",", period: "。")
: .init(comma: ",", period: "."))
: nil
let layout = TypingKeyLayoutBuilder.build(
size: size,
letterRows: typing.keyRows,
pageSwitchLabel: pageLabel,
spaceLabel: spaceLabel,
returnLabel: returnLabel,
metrics: metrics,
includeGlobeKey: state.showsSystemGlobeKey,
punctuationKeys: punctuationKeys,
// iPad top letter row carries the small number overlay (1–0),
// mirroring the iOS system keyboard. iPhone keeps the clean row.
showTopRowNumbers: isIPad,
keyWeight: { label, index, rowIndex in
keyWeight(label: label, index: index, rowIndex: rowIndex)
}
)
let validNext = PinyinNextKeyResolver.validNextKeys(
rawInput: typing.composition.rawInput,
schema: typing.schema,
language: typing.language,
page: typing.page
)
return layout.withHitWeights(
PinyinNextKeyResolver.hitWeights(for: layout.keys, validNext: validNext)
)
}
@ViewBuilder
private func visualTypingKey(_ key: TypingKeyHitTarget) -> some View {
let pressed = highlightedKeyIDs.contains(key.id)
let isBottom = key.id.hasPrefix("bottom.")
let isShift = key.label == "⇧"
let shiftLit = isShift && typing.isShiftEnabled
let showPressed = pressed || shiftLit
Group {
if key.label == "⌫" {
Image(systemName: "delete.left")
.font(TypeStyle.title3.weight(.medium))
.foregroundStyle(keyTextColor)
} else if key.label == "⇧" {
Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift")
.font(.system(size: 19, weight: .medium))
.foregroundStyle(keyTextColor)
} else if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
returnKeyLabel
.foregroundStyle(returnKeyTextColor)
} else if isBottom {
let isSpecial = key.id == TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue
Text(key.label)
.font(
.system(
size: isSpecial ? 15 : 17,
weight: isSpecial ? .semibold : .regular
)
)
.foregroundStyle(keyTextColor)
} else {
// Letter / character key. On iPad the top row carries a small
// grey number overlay (1–0), mirroring the iOS system keyboard;
// `displayNumber` is nil everywhere else, so the layout is a
// single centred letter there.
VStack(spacing: 1) {
if let number = key.displayNumber {
Text(number)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(palette.textSecondary.opacity(0.7))
}
let isSpecial = ["123", "#+=", "ABC"].contains(key.label)
Text(key.label)
.font(
.system(
size: isSpecial ? 15 : 22,
weight: isSpecial ? .semibold : .regular
)
)
.foregroundStyle(keyTextColor)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.fill(visualKeyFill(for: key, pressed: showPressed))
)
// 无投影:与 NativeKeyboardKeySurface 一致,靠填充 + 描边表达层次。
.overlay(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.stroke(visualKeyBorder(for: key), lineWidth: 0.5)
)
.scaleEffect(pressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: pressed)
.accessibilityElement()
.accessibilityAddTraits(.isButton)
.accessibilityLabel(Text(accessibilityLabel(for: key)))
.accessibilityAction {
commitTypingKey(key)
}
}
private func visualKeyFill(for key: TypingKeyHitTarget, pressed: Bool) -> Color {
if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
return pressed ? returnKeyPressedFill : returnKeyFill
}
return pressed ? keyPressedFill : keyFill
}
private func visualKeyBorder(for key: TypingKeyHitTarget) -> Color {
if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
return returnKeyBorder
}
return palette.divider
}
private func accessibilityLabel(for key: TypingKeyHitTarget) -> String {
switch key.id {
case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue:
return returnKeyAccessibilityLabel
case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue:
return key.label
default:
if key.label == "⇧" { return ExtL10n.string("keyboard.key.shiftA11y") }
if key.label == "⌫" { return ExtL10n.string("keyboard.key.deleteA11y") }
return key.label
}
}
private func commitTypingKey(_ key: TypingKeyHitTarget) {
guard canAcceptTypingKeys else { return }
switch key.id {
case TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue:
typing.setPage(typing.page == .letters ? .numbers : .letters)
case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue:
apply(typing.handleSpace())
case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue:
apply(typing.handleReturn())
case TypingKeyLayoutBuilder.BottomKeyID.comma.rawValue,
TypingKeyLayoutBuilder.BottomKeyID.period.rawValue:
// Route through the engine so a pending composition commits first,
// exactly as punctuation typed from the symbols page does.
apply(typing.handleKey(key.label))
default:
switch key.behavior {
case .commitOnRelease:
apply(typing.handleKey(key.label))
case .deleteRepeat:
apply(typing.handleKey("⌫"))
case .shiftHold:
// Mirror a completed Shift tap for VoiceOver / accessibility.
typing.beginShiftHold()
typing.endShiftHold()
}
}
}
@ViewBuilder
private var returnKeyLabel: some View {
if state.returnKeyRole.usesActionFill {
ExtL10n.text(state.returnKeyRole.titleKey)
.font(TypeStyle.body.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.6)
} else {
Image(systemName: "arrow.turn.down.left")
.font(.system(size: 21, weight: .medium))
}
}
private func apply(_ output: TypingOutput) {
guard !output.isEmpty else {
typing.syncAutocapitalization()
return
}
if output.deleteCount > 0 {
for _ in 0..<output.deleteCount {
onDeleteBackward()
}
}
if !output.text.isEmpty {
onInsert(output.text)
}
// Notes / some hosts lag `documentContextBeforeInput` — pass the edit we
// just applied so sentence Shift can arm after Return / "." immediately.
typing.syncAutocapitalization(
accountingForInsert: output.text,
deleteCount: output.deleteCount
)
}
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
// Match system: page switchers + delete are wider than character keys.
if label == "⌫" || label == "⇧" || label == "#+=" || label == "123" {
return 1.35
}
if rowIndex == 2 && index == 0 {
// Double-pinyin semicolon occupies the normal Shift footprint.
return 1.35
}
return 1
}
private var keyFill: Color {
NativeKeyboardKeyColors.fill(for: colorScheme)
}
private var keyPressedFill: Color {
NativeKeyboardKeyColors.pressedFill(for: colorScheme)
}
private var keyTextColor: Color {
NativeKeyboardKeyColors.text(for: colorScheme)
}
private var selectedCandidateFill: Color {
colorScheme == .dark
? OSGColor.keyboardDarkInputFillRaised
: OSGColor.fixedLightContent
}
private var returnKeyFill: Color {
state.returnKeyRole.usesActionFill ? sendKeyFill : keyFill
}
private var returnKeyPressedFill: Color {
state.returnKeyRole.usesActionFill ? sendKeyPressedFill : keyPressedFill
}
private var returnKeyBorder: Color {
if state.returnKeyRole.usesActionFill {
return colorScheme == .dark
? OSGColor.keyboardActionBorderDark
: OSGColor.keyboardActionBorderLight
}
return palette.divider
}
private var returnKeyAccessibilityLabel: String {
if state.returnKeyRole.usesActionFill {
return ExtL10n.string(state.returnKeyRole.titleKey)
}
return ExtL10n.string("keyboard.key.returnA11y")
}
private var returnKeyTextColor: Color {
state.returnKeyRole.usesActionFill ? OSGColor.fixedLightContent : keyTextColor
}
/// The send key stays recognizable in both appearances without becoming neon.
private var sendKeyFill: Color {
NativeKeyboardKeyColors.sendFill(for: colorScheme)
}
private var sendKeyPressedFill: Color {
NativeKeyboardKeyColors.sendPressedFill(for: colorScheme)
}
}