fix(ipad): ship iPad P0 layout/globe fixes, edit-last-input, drop clipboard commands

Adapt typing/voice surfaces for iPad width and height, add the system globe
key and last-input editing flow, harden host-only Rime deployment, and remove
clipboard voice commands. Bump build to 61.
This commit is contained in:
Rocky
2026-08-10 13:50:50 +08:00
parent 53abad2050
commit 2bc8c1b87d
85 changed files with 5703 additions and 3997 deletions
@@ -0,0 +1,124 @@
// GlobeInputModeButton.swift
// OSGKeyboard · Keyboard Extension
//
// System "next keyboard" (🌐) control. Registering
// `handleInputModeList(from:with:)` for all touch events lets UIKit provide
// native tap-to-advance and long-press input-mode selection without retaining
// a transient `UIEvent`. SwiftUI draws the shared native key chrome while a
// transparent `UIButton` owns touch delivery.
import SwiftUI
import UIKit
import OSGKeyboardShared
struct GlobeInputModeButton: UIViewRepresentable {
@ObservedObject var state: KeyboardState
@Binding var isPressed: Bool
func makeUIView(context: Context) -> GlobeInputModeButtonView {
let view = GlobeInputModeButtonView()
view.onHighlightChanged = { isPressed = $0 }
view.setInputModeController(state.inputModeController)
return view
}
func updateUIView(_ uiView: GlobeInputModeButtonView, context: Context) {
uiView.onHighlightChanged = { isPressed = $0 }
uiView.setInputModeController(state.inputModeController)
}
static func dismantleUIView(_ uiView: GlobeInputModeButtonView, coordinator: Void) {
uiView.onHighlightChanged = nil
uiView.setInputModeController(nil)
}
}
/// SwiftUI wrapper that frames the UIKit globe button and wires it to the
/// shared `KeyboardState` action hooks. Used on both iPad voice and typing
/// surfaces; iPhone relies on the system-provided switch below the keyboard.
/// Default 44×30 remains available for previews; bottom action rows pass an
/// explicit width / height so the key shares their complete geometry.
struct SystemGlobeKey: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
@ObservedObject var state: KeyboardState
@State private var isPressed = false
var width: CGFloat = 44
var height: CGFloat = 30
var body: some View {
ZStack {
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: NativeKeyboardKeyColors.fill(for: colorScheme),
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
border: palette.divider,
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
) {
Image(systemName: "globe")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
.accessibilityHidden(true)
}
GlobeInputModeButton(state: state, isPressed: $isPressed)
.accessibilityLabel(ExtL10n.text("keyboard.nextKeyboardA11y"))
.accessibilityHint(ExtL10n.text("keyboard.nextKeyboardA11yHint"))
}
.frame(width: width, height: height)
}
}
final class GlobeInputModeButtonView: UIButton {
var onHighlightChanged: ((Bool) -> Void)?
/// UIKit controls do not retain action targets, but keeping this explicitly
/// weak documents and enforces the keyboard controller ownership boundary.
private weak var inputModeController: UIInputViewController?
private let inputModeAction = #selector(UIInputViewController.handleInputModeList(from:with:))
override var isHighlighted: Bool {
didSet {
guard oldValue != isHighlighted else { return }
onHighlightChanged?(isHighlighted)
}
}
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configure() {
// SwiftUI renders the icon, fill, border, shadow, and pressed state.
// This UIKit layer stays transparent and handles only system gestures.
backgroundColor = .clear
isExclusiveTouch = true
accessibilityTraits = .keyboardKey
}
func setInputModeController(_ controller: UIInputViewController?) {
guard inputModeController !== controller else { return }
if let inputModeController {
removeTarget(
inputModeController,
action: inputModeAction,
for: .allTouchEvents
)
}
inputModeController = controller
if let controller {
addTarget(
controller,
action: inputModeAction,
for: .allTouchEvents
)
}
}
}
+166 -136
View File
@@ -35,7 +35,8 @@ private enum KeyboardLayoutMetrics {
/// park delete/return at the far screen edges and turn each cursor-drag
/// pad into a ~450 pt runway capping keeps the reach ergonomics of the
/// phone layout. iPhone widths are all below this, so it is a no-op there.
static let contentMaxWidth: CGFloat = KeyboardChromeLayout.contentMaxWidth
/// The typing surface deliberately does not share this cap.
static let contentMaxWidth: CGFloat = KeyboardChromeLayout.voiceContentMaxWidth
// MARK: - Content-driven keyboard height (single source of truth)
static let outerPaddingTop: CGFloat = 4
@@ -59,13 +60,16 @@ private enum KeyboardLayoutMetrics {
/// Pushes the transcript / hint line down to the vertical centre of the gap
/// between the tab capsule's bottom edge and the mic's visible top edge.
/// Applied as an offset so the band heights and therefore `totalHeight`
/// and `micUpwardAdjustment` stay untouched.
static var transcriptLineDownwardAdjustment: CGFloat {
/// and `micUpwardAdjustment` stay untouched. `extraSpace` is the slack a
/// taller iPad keyboard adds above the action cluster, which moves the mic
/// down and so must move this line with it.
static func transcriptLineDownwardAdjustment(extraSpace: CGFloat) -> CGFloat {
let capsuleBottom = (topBarHeight + topBarTabCapsuleHeight) / 2
let micVisibleTop = topBarHeight
+ topBarToTranscriptSpacing
+ transcriptLineHeight
+ actionClusterTopGap
+ extraSpace
- micUpwardAdjustment
+ micRingInset
let currentCentre = topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight / 2
@@ -76,8 +80,21 @@ private enum KeyboardLayoutMetrics {
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
}
/// 4 + 70 + 24 + 179 + 0 + 4 = 281 pt, matching Chinese / English.
/// 4 + 70 + 24 + 179 + 0 + 4 = 281 pt on phones.
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
/// Voice and typing must resolve to the same height or switching surfaces
/// visibly resizes the keyboard 113 pt on an iPad in landscape. The
/// typing surface is content-driven, so voice adopts its height and parks
/// the surplus above the action cluster (keeping the bottom row on the
/// same baseline as the typing bottom row).
static func totalHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
}
static func extraVerticalSpace(isIPad: Bool, width: CGFloat) -> CGFloat {
max(0, totalHeight(isIPad: isIPad, width: width) - totalHeight)
}
}
public struct KeyboardRootView: View {
@@ -101,11 +118,18 @@ public struct KeyboardRootView: View {
/// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`).
static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight
/// Matches the typing surface so switching surfaces never resizes the
/// keyboard. Surplus height is parked above the action cluster.
static func totalHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
KeyboardLayoutMetrics.totalHeight(isIPad: isIPad, width: width)
}
// MARK: - Cursor-drag pad geometry
/// Mic disc side length.
static let micSize: CGFloat = KeyboardLayoutMetrics.micSize
/// Vertical offset from the keyboard's top edge to the mic disc.
/// Vertical offset from the keyboard's top edge to the mic disc. iPad adds
/// `KeyboardLayoutMetrics.extraVerticalSpace` on top of this.
static let micTopOffset: CGFloat = KeyboardLayoutMetrics.outerPaddingTop
+ KeyboardLayoutMetrics.headerBandHeight
+ KeyboardLayoutMetrics.actionClusterTopGap
@@ -118,30 +142,48 @@ public struct KeyboardRootView: View {
}
public var body: some View {
ZStack {
VStack(spacing: 0) {
headerBand
Group {
if state.editSession.isActive {
LastInputEditView(state: state)
} else {
ZStack {
VStack(spacing: 0) {
headerBand
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
micActionRow
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
// Absorbs the surplus of a taller iPad keyboard here so
// the action cluster stays pinned to the bottom and its
// keys share the typing surface's bottom-row baseline.
Spacer(minLength: 0)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
micActionRow
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
}
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
// chrome
.background(Color.clear)
// No content-width cap: the surface fills the host width so
// switching between voice and typing never changes width.
.frame(maxWidth: .infinity)
.frame(
height: Self.totalHeight(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
)
)
// Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette)
}
}
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
// chrome
.background(Color.clear)
.frame(maxWidth: KeyboardLayoutMetrics.contentMaxWidth)
.frame(maxWidth: .infinity)
.frame(height: Self.totalHeight)
// Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette)
}
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
.animation(.easeInOut(duration: 0.12), value: state.editSession.isActive)
}
/// Top brand / mode row + transcript / hint line.
@@ -155,13 +197,18 @@ public struct KeyboardRootView: View {
transcript: state.lastTranscript,
micVoiceAvailability: state.micVoiceAvailability,
micDisabledHint: state.micDisabledHint,
clipboardCommandEligible: state.clipboardCommandEligible,
clipboardFailureHint: state.clipboardFailureHint,
editHint: state.editHint,
editHintIsPositive: state.editHintIsPositive,
cursorDragHintActive: state.cursorDragActive,
openSettings: state.openSettings
)
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
.offset(y: KeyboardLayoutMetrics.transcriptLineDownwardAdjustment)
.offset(y: KeyboardLayoutMetrics.transcriptLineDownwardAdjustment(
extraSpace: KeyboardLayoutMetrics.extraVerticalSpace(
isIPad: state.usesIPadLayoutMetrics,
width: state.layoutWidth
)
))
}
}
@@ -170,15 +217,25 @@ public struct KeyboardRootView: View {
private var topBar: some View {
HStack(spacing: Spacing.xs) {
KeyboardBrandLogo(action: state.openSettings)
// Globe key now lives at the bottom-left of the keyboard (matching
// iOS system layout); see micActionRow's bottom HStack.
// Engine controls remain available in the host app.
// App context is auto-detected on each mic press no UI.
Spacer(minLength: 0)
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
if state.canCancelVoiceInput {
KeyboardCancelButton(
action: state.cancelVoiceInput,
accessibilityLabel: ExtL10n.text("keyboard.voice.cancel"),
accessibilityHint: ExtL10n.text("keyboard.voice.cancelHint")
)
} else {
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
}
}
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
}
@@ -199,19 +256,12 @@ public struct KeyboardRootView: View {
// opacity so the pads' hit area never shifts mid-gesture) and lets
// the cursor-drag chrome take over.
let dragging = state.cursorDragActive
let clipboardRecording = state.clipboardCommandRecording
// Undo hides during drag (like mic) and during clipboard side captions.
let undoVisible = !dragging && !clipboardRecording
let recording = state.phase == .recording
let undoVisible = !dragging && !recording
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
HStack(spacing: 0) {
cursorDragPad(enabled: cursorPadsEnabled)
.overlay {
clipboardSideHint(
ExtL10n.text("keyboard.clipboard.recordingLeft"),
visible: clipboardRecording && !dragging
)
}
.overlay(alignment: .leading) {
// Left-handed: undo shares the outer edge with delete.
if !swapKeys {
@@ -227,12 +277,12 @@ public struct KeyboardRootView: View {
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
isEnabled: micButtonEnabled,
isClipboardCommandRecording: state.clipboardCommandRecording,
onToggle: state.tapMic,
onClipboardLongPressBegan: (state.clipboardCommandEligible
|| state.clipboardCommandUtteranceActive)
&& micButtonEnabled
? state.beginClipboardCommand
onPressingChanged: micButtonEnabled
? state.setMicTouchActive
: { _ in },
onEditLongPressBegan: micButtonEnabled
? state.beginEditLastInput
: nil
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
@@ -240,12 +290,6 @@ public struct KeyboardRootView: View {
.opacity(dragging ? 0 : 1)
cursorDragPad(enabled: cursorPadsEnabled)
.overlay {
clipboardSideHint(
ExtL10n.text("keyboard.clipboard.recordingRight"),
visible: clipboardRecording && !dragging
)
}
.overlay(alignment: .trailing) {
// Right-handed: undo mirrors to the outer (delete) side.
if swapKeys {
@@ -257,33 +301,74 @@ public struct KeyboardRootView: View {
}
}
.frame(height: KeyboardLayoutMetrics.micSize)
.animation(.easeInOut(duration: 0.25), value: state.clipboardCommandRecording)
GeometryReader { proxy in
let widths = KeyboardChromeLayout.actionKeyWidths(
availableWidth: proxy.size.width
)
if state.showsSystemGlobeKey {
// iPad uses a flatter split: at full width the phone's 50%
// centre fraction would hand return ~577 pt.
let widths = state.usesIPadLayoutMetrics
? KeyboardChromeLayout.iPadVoiceActionKeyWidths(
availableWidth: proxy.size.width
)
: KeyboardChromeLayout.actionKeyWidths(
availableWidth: proxy.size.width
)
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side)
} else {
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side)
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
// Globe key pins to the far-left of the iPad action row.
// Tap advances; long-press presents the system list.
SystemGlobeKey(
state: state,
width: widths.globe,
height: KeyboardLayoutMetrics.bottomActionRowHeight
)
.frame(
width: widths.globe,
height: KeyboardLayoutMetrics.bottomActionRowHeight
)
if swapKeys {
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side2)
} else {
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side2)
}
}
} else {
let widths = KeyboardChromeLayout.actionKeyWidthsWithoutGlobe(
availableWidth: proxy.size.width
)
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side2)
} else {
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side2)
}
}
}
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
.opacity(dragging ? 0 : 1)
.opacity(dragging || recording ? 0 : 1)
.allowsHitTesting(!dragging && !recording)
}
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
.frame(maxWidth: .infinity)
@@ -300,23 +385,6 @@ public struct KeyboardRootView: View {
.contentShape(Rectangle())
}
/// Side caption beside the mic during clipboard-command recording.
/// Vertically matches the mic disc (same upward offset); does not steal touches.
private func clipboardSideHint(_ text: Text, visible: Bool) -> some View {
text
// 22pt ~18pt (20%); softer than body so it doesn't compete with the mic.
.font(.system(size: 17.6, weight: .medium))
.foregroundStyle(palette.textSecondary.opacity(0.42))
.multilineTextAlignment(.center)
.lineLimit(3)
.minimumScaleFactor(0.7)
.padding(.horizontal, 2)
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
.opacity(visible ? 1 : 0)
.allowsHitTesting(false)
.accessibilityHidden(!visible)
}
private func bottomDeleteButton(disabled: Bool) -> some View {
RepeatingDeleteButton(
disabled: disabled,
@@ -385,14 +453,6 @@ public struct KeyboardRootView: View {
}
private var buttonPhase: RecordButton.Phase {
switch clipboardMicChrome {
case .preparingCancelable:
return .preparing
case .recordingBlue:
return .recording
case .none:
break
}
switch state.micVoiceAvailability {
case .recording:
return .recording
@@ -405,30 +465,12 @@ public struct KeyboardRootView: View {
}
}
/// Preparing clipboard capture: grey spinner, tap to cancel.
/// Disabled only when the shared voice prerequisites are unavailable.
private var micButtonEnabled: Bool {
if state.micDisabled { return false }
return true
}
private var clipboardMicChrome: ClipboardMicChrome {
let phase: ClipboardPreparingPhase = {
switch state.phase {
case .idle: return .idle
case .denied: return .denied
case .error: return .error
case .requestingPermissions: return .requestingPermissions
case .recording: return .recording
case .processing: return .processing
}
}()
return ClipboardPreparingPolicy.micChrome(
isClipboardUtterance: state.clipboardCommandUtteranceActive,
phase: phase,
awaitingHostConfirm: state.phase == .requestingPermissions
|| (state.phase == .recording && !state.clipboardCommandRecording)
)
}
}
// MARK: - State alias
@@ -477,8 +519,8 @@ private struct TranscriptLine: View {
let transcript: String
let micVoiceAvailability: MicVoiceAvailability
let micDisabledHint: String
let clipboardCommandEligible: Bool
let clipboardFailureHint: String?
let editHint: String?
let editHintIsPositive: Bool
let cursorDragHintActive: Bool
let openSettings: () -> Void
@@ -551,10 +593,10 @@ private struct TranscriptLine: View {
@ViewBuilder
private var idleHint: some View {
if let clipboardFailureHint, !clipboardFailureHint.isEmpty {
Text(clipboardFailureHint)
if let editHint, !editHint.isEmpty {
Text(editHint)
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.foregroundStyle(editHintIsPositive ? palette.accent : palette.warning)
.lineLimit(1)
.truncationMode(.tail)
} else {
@@ -571,25 +613,13 @@ private struct TranscriptLine: View {
Group {
switch micVoiceAvailability {
case .ready:
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
ExtL10n.text("keyboard.placeholder.idle")
case .unavailable(.missingAPIKey):
Text(micDisabledHint)
case .unavailable(.hostNotReady):
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
ExtL10n.text("keyboard.placeholder.idle")
case .unavailable(.preparingSession):
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
ExtL10n.text("keyboard.placeholder.idle")
case .unavailable(.noFullAccess):
ExtL10n.text("keyboard.error.fullAccessRequired")
case .unavailable(.appGroupUnavailable):
+41 -1
View File
@@ -40,6 +40,38 @@ struct KeyboardBrandLogo: View {
}
}
struct KeyboardCancelButton: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
let action: () -> Void
let accessibilityLabel: Text
let accessibilityHint: Text
var body: some View {
Button(action: action) {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(width: 34, height: 34)
.background(buttonFill, in: Circle())
.overlay(
Circle()
.stroke(palette.divider, lineWidth: 0.5)
)
.frame(width: 44, height: 44)
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
.accessibilityHint(accessibilityHint)
}
private var buttonFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
}
}
private enum KeyboardInputTab: CaseIterable {
case voice
case chinese
@@ -90,7 +122,7 @@ struct KeyboardTopControls: View {
}
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
.disabled(tab != .voice && !state.canEnterTypingSurface)
.opacity(tab != .voice && !state.canEnterTypingSurface ? 0.42 : 1)
.opacity(tabOpacity(tab))
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(isSelected(tab) ? .isSelected : [])
}
@@ -113,6 +145,14 @@ struct KeyboardTopControls: View {
colorScheme == .dark ? Color(white: 0.38) : .white
}
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
guard tab != .voice, !state.canEnterTypingSurface else { return 1 }
if case .recording = state.phase {
return 0
}
return 0.42
}
private var trackFill: Color {
colorScheme == .dark ? Color(white: 0.18) : Color.black.opacity(0.08)
}
@@ -0,0 +1,253 @@
// LastInputEditView.swift
// OSGKeyboard · Keyboard Extension
import SwiftUI
import OSGKeyboardShared
struct LastInputEditView: View {
private enum Layout {
static let primaryButtonHeight: CGFloat = 50
static let primaryButtonWidth: CGFloat = primaryButtonHeight * 3
}
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@State private var selectedPage: Int? = 0
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
var body: some View {
VStack(spacing: 0) {
topBar.frame(height: KeyboardTopBarMetrics.height)
VStack(spacing: 0) {
ZStack(alignment: .bottom) {
pages
.frame(maxWidth: .infinity, maxHeight: .infinity)
VStack(spacing: 0) {
statusLine.frame(height: 18)
pageIndicator.frame(height: 12)
}
.allowsHitTesting(false)
}
.frame(height: 174)
.contentShape(Rectangle())
.simultaneousGesture(reviewSwipeGesture)
primaryRow.frame(height: 55)
}
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: .infinity)
.frame(height: KeyboardChromeLayout.totalHeight)
.environment(\.themePalette, palette)
.onChange(of: state.editSession) { _, newValue in
guard newValue.review != nil else {
selectedPage = 0
return
}
if reduceMotion {
selectedPage = 1
} else {
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.86)) {
selectedPage = 1
}
}
}
}
private var topBar: some View {
HStack {
KeyboardBrandLogo(action: state.openSettings)
Spacer(minLength: 0)
KeyboardCancelButton(
action: state.closeEditMode,
accessibilityLabel: ExtL10n.text("keyboard.edit.close"),
accessibilityHint: ExtL10n.text("keyboard.edit.closeHint")
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
@ViewBuilder
private var pages: some View {
if let source = state.editSession.source {
EditTextPager(
originalTitle: ExtL10n.string("keyboard.edit.page.original"),
originalText: source.reference.displayText,
editedTitle: ExtL10n.string("keyboard.edit.page.edited"),
editedText: state.editSession.review?.resultText,
contentBottomInset: 30,
selectedPage: $selectedPage
)
}
}
private var statusLine: some View {
Text(editTranscript)
.font(TypeStyle.caption)
.foregroundStyle(isFailure ? palette.warning : palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: .infinity)
}
private var pageIndicator: some View {
HStack(spacing: 5) {
Circle()
.fill(selectedPage != 1 ? palette.textPrimary : palette.textTertiary.opacity(0.45))
.frame(width: 5, height: 5)
Circle()
.fill(selectedPage == 1 ? palette.textPrimary : palette.textTertiary.opacity(0.45))
.frame(width: 5, height: 5)
}
.opacity(state.editSession.review == nil ? 0 : 1)
.accessibilityHidden(true)
}
private var primaryRow: some View {
HStack(spacing: Spacing.sm) {
helperText(leftHelper)
Button(action: primaryAction) {
ZStack {
Capsule().fill(palette.accent)
if case .listening = state.editSession {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
.scaleEffect(1 + min(max(state.level, 0), 1) * 0.08)
.animation(Motion.soft, value: state.level)
}
primaryIcon
}
.frame(
width: Layout.primaryButtonWidth,
height: Layout.primaryButtonHeight
)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.disabled(primaryDisabled)
.accessibilityLabel(Text(primaryAccessibilityLabel))
helperText(rightHelper)
}
}
private func helperText(_ value: String) -> some View {
Text(value)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary.opacity(0.55))
.multilineTextAlignment(.center)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.simultaneousGesture(reviewSwipeGesture)
}
@ViewBuilder
private var primaryIcon: some View {
switch state.editSession {
case .preparing, .processing, .applying, .appending:
ProgressView().tint(.white)
case .review:
Image(systemName: "checkmark")
.font(.system(size: 21, weight: .bold))
.foregroundStyle(.white)
case .listening:
WaveformView(
level: state.level,
barCount: 7,
color: .white,
active: true
)
.frame(width: 35, height: 22)
.clipped()
default:
Image(systemName: "mic.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(.white)
}
}
private var primaryDisabled: Bool {
switch state.editSession {
case .preparing, .processing, .applying, .appending:
return true
default:
return false
}
}
private func primaryAction() {
switch state.editSession {
case .listening:
state.stopEditListening()
case .review:
state.confirmEditResult()
case .failed:
state.beginEditLastInput()
default:
break
}
}
private var editTranscript: String {
if case .failed(_, let message) = state.editSession {
return message
}
return state.lastTranscript
}
private var isFailure: Bool {
if case .failed = state.editSession { return true }
return false
}
private var leftHelper: String {
state.editSession.review == nil
? ExtL10n.string("keyboard.edit.helper.speak")
: ExtL10n.string("keyboard.edit.helper.compare")
}
private var rightHelper: String {
if state.editSession.review != nil {
return state.editCanReplaceOriginal
? ExtL10n.string("keyboard.edit.helper.apply")
: ExtL10n.string("keyboard.edit.helper.append")
}
return ExtL10n.string("keyboard.edit.helper.finish")
}
private var primaryAccessibilityLabel: String {
if state.editSession.review != nil {
return state.editCanReplaceOriginal
? ExtL10n.string("keyboard.edit.apply")
: ExtL10n.string("keyboard.edit.append")
}
return ExtL10n.string("keyboard.edit.stop")
}
private var reviewSwipeGesture: some Gesture {
DragGesture(minimumDistance: 12)
.onEnded { value in
guard state.editSession.review != nil else { return }
let translation = value.predictedEndTranslation
guard abs(translation.width) > abs(translation.height) * 1.2,
abs(translation.width) >= 28 else {
return
}
let targetPage = translation.width < 0 ? 1 : 0
guard selectedPage != targetPage else { return }
if reduceMotion {
selectedPage = targetPage
} else {
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.86)) {
selectedPage = targetPage
}
}
}
}
}