chore(release): bump version to 2.1.6 (build 131)
Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs, docs/llms.txt). fix(flow): host-app identification now compiles in every build configuration (Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and could not auto-return after a startflow handoff. Debug-only logging and the Home test card are unchanged. Also includes the pending work in the tree: - Home: single account/setup card surface (HomeAccountSurfacePolicy/Views) - Wide screens: voice key row and centred mic placement, iPhone Duo layout - Keyboard: clipboard skill ranking and AI skill layout updates - Debug: account session handoff to the Simulator (+ import script) - Docs: portfolio assets, privacy policy URL, site/README refresh
This commit is contained in:
@@ -106,6 +106,25 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// Regular × regular on a phone host is the unfolded iPhone Duo; every
|
||||
/// other iPhone posture has at least one compact axis.
|
||||
private var isExpandedPhoneLayout: Bool {
|
||||
KeyboardChromeLayout.usesExpandedPhoneMetrics(
|
||||
isPad: UIDevice.current.userInterfaceIdiom == .pad,
|
||||
hasRegularWidth: traitCollection.horizontalSizeClass == .regular,
|
||||
hasRegularHeight: traitCollection.verticalSizeClass == .regular
|
||||
)
|
||||
}
|
||||
|
||||
/// Compact height on a phone host is a landscape iPhone (folded Duo
|
||||
/// included). `.unspecified` before the first trait pass is not compact.
|
||||
private var isCompactHeightPhoneLayout: Bool {
|
||||
KeyboardChromeLayout.usesCompactHeightPhoneMetrics(
|
||||
isPad: UIDevice.current.userInterfaceIdiom == .pad,
|
||||
hasCompactHeight: traitCollection.verticalSizeClass == .compact
|
||||
)
|
||||
}
|
||||
|
||||
/// Width the layout should be sized against. `view.bounds` is empty before
|
||||
/// the first layout pass, so fall back to the screen the keyboard is on.
|
||||
private var currentLayoutWidth: CGFloat {
|
||||
@@ -120,7 +139,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
KeyboardSurfaceRoot.height(
|
||||
for: state.surface,
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,7 +179,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
#if DEBUG
|
||||
HostBundleProbe.installLogging()
|
||||
#endif
|
||||
state.showsSystemGlobeKey = UIDevice.current.userInterfaceIdiom == .pad
|
||||
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
||||
primaryLanguage = "mis"
|
||||
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
||||
@@ -359,7 +379,11 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
|
||||
}
|
||||
#if DEBUG
|
||||
HostBundleProbe.recordPresentationAndPing()
|
||||
HostBundleProbe.recordPresentation()
|
||||
#endif
|
||||
// Host identification feeds the cold-start auto-return; runs in every build.
|
||||
HostAppIdentity.refreshOnPresentation()
|
||||
#if DEBUG
|
||||
WhatsNewDemoDriver.resetForNewPresentation()
|
||||
WhatsNewDemoDriver.startIfNeeded(
|
||||
state: state,
|
||||
@@ -404,7 +428,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
guard previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass else {
|
||||
guard previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass
|
||||
|| previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass else {
|
||||
return
|
||||
}
|
||||
refreshLayoutMode()
|
||||
@@ -949,24 +974,76 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
|
||||
private func refreshLayoutMode() {
|
||||
// Folding moves the keyboard between a display with a system switch
|
||||
// below it and one without, so re-read this on every layout pass.
|
||||
refreshInputModeSwitchKey()
|
||||
let usesIPadMetrics = isIPadLayout
|
||||
let usesExpandedPhoneMetrics = isExpandedPhoneLayout
|
||||
let usesCompactHeightPhoneMetrics = isCompactHeightPhoneLayout
|
||||
let width = currentLayoutWidth
|
||||
// `state.layoutWidth` tracks the width that last changed the layout
|
||||
// bucket, not every intermediate width: republishing on each frame of
|
||||
// a Stage Manager drag would rebuild the SwiftUI grid continuously.
|
||||
let bucketChanged = KeyboardChromeLayout.usesWideIPadMetrics(
|
||||
let bucketChanged = Self.layoutWidthBucket(
|
||||
isIPad: usesIPadMetrics,
|
||||
isExpandedPhone: usesExpandedPhoneMetrics,
|
||||
isCompactHeightPhone: usesCompactHeightPhoneMetrics,
|
||||
width: width
|
||||
) != KeyboardChromeLayout.usesWideIPadMetrics(
|
||||
) != Self.layoutWidthBucket(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
)
|
||||
guard state.usesIPadLayoutMetrics != usesIPadMetrics || bucketChanged else { return }
|
||||
guard state.usesIPadLayoutMetrics != usesIPadMetrics
|
||||
|| state.usesExpandedPhoneLayoutMetrics != usesExpandedPhoneMetrics
|
||||
|| state.usesCompactHeightPhoneLayoutMetrics != usesCompactHeightPhoneMetrics
|
||||
|| bucketChanged else { return }
|
||||
state.usesIPadLayoutMetrics = usesIPadMetrics
|
||||
state.usesExpandedPhoneLayoutMetrics = usesExpandedPhoneMetrics
|
||||
state.usesCompactHeightPhoneLayoutMetrics = usesCompactHeightPhoneMetrics
|
||||
state.layoutWidth = width
|
||||
refreshKeyboardHeight()
|
||||
}
|
||||
|
||||
/// Every width threshold the SwiftUI surfaces branch on. The first layout
|
||||
/// pass can report a transient width (0, or the folded display's), so a
|
||||
/// threshold missing here would keep that stale width for the session.
|
||||
private static func layoutWidthBucket(
|
||||
isIPad: Bool,
|
||||
isExpandedPhone: Bool,
|
||||
isCompactHeightPhone: Bool,
|
||||
width: CGFloat
|
||||
) -> LayoutWidthBucket {
|
||||
LayoutWidthBucket(
|
||||
wideIPad: KeyboardChromeLayout.usesWideIPadMetrics(isIPad: isIPad, width: width),
|
||||
voiceKeyRow: VoiceKeyRowLayout.isAvailable(
|
||||
usesIPadMetrics: isIPad,
|
||||
usesExpandedPhoneMetrics: isExpandedPhone,
|
||||
usesCompactHeightPhoneMetrics: isCompactHeightPhone,
|
||||
hostWidth: width
|
||||
),
|
||||
compactVoice: KeyboardChromeLayout.usesCompactVoiceMetrics(width: width),
|
||||
compactIPadVoice: KeyboardChromeLayout.usesCompactIPadVoiceMetrics(isIPad: isIPad, width: width)
|
||||
)
|
||||
}
|
||||
|
||||
private struct LayoutWidthBucket: Equatable {
|
||||
let wideIPad: Bool
|
||||
let voiceKeyRow: Bool
|
||||
let compactVoice: Bool
|
||||
let compactIPadVoice: Bool
|
||||
}
|
||||
|
||||
private func refreshInputModeSwitchKey() {
|
||||
let shows = KeyboardChromeLayout.showsInputModeSwitchKey(
|
||||
isPad: UIDevice.current.userInterfaceIdiom == .pad,
|
||||
systemRequiresSwitchKey: needsInputModeSwitchKey
|
||||
)
|
||||
guard state.showsSystemGlobeKey != shows else { return }
|
||||
state.showsSystemGlobeKey = shows
|
||||
}
|
||||
|
||||
private var heightPhaseLog: String {
|
||||
switch heightPhase {
|
||||
case .idle: return "idle"
|
||||
@@ -1209,7 +1286,11 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
+ "view=\(Int(view.bounds.height)) "
|
||||
+ "host=\(Int(hosting?.view.bounds.height ?? -1)) "
|
||||
+ "constraint=\(Int(keyboardHeightConstraint?.constant ?? -1)) "
|
||||
+ "target=\(Int(targetKeyboardHeight))"
|
||||
+ "target=\(Int(targetKeyboardHeight)) "
|
||||
+ "width=\(Int(view.bounds.width)) "
|
||||
+ "sizeClass=\(traitCollection.horizontalSizeClass.rawValue)x"
|
||||
+ "\(traitCollection.verticalSizeClass.rawValue) "
|
||||
+ "needsGlobe=\(needsInputModeSwitchKey ? 1 : 0)"
|
||||
guard snapshot != lastLoggedLayoutSnapshot else { return }
|
||||
lastLoggedLayoutSnapshot = snapshot
|
||||
OSGDiag.log("KVC.layout \(snapshot)", category: "boot")
|
||||
|
||||
@@ -38,9 +38,16 @@ struct KeyboardSurfaceRoot: View {
|
||||
static func height(
|
||||
for surface: KeyboardState.Surface,
|
||||
isIPad: Bool = false,
|
||||
width: CGFloat = 0
|
||||
width: CGFloat = 0,
|
||||
isExpandedPhone: Bool = false,
|
||||
isCompactHeightPhone: Bool = false
|
||||
) -> CGFloat {
|
||||
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
|
||||
TypingSurfaceMetrics.contentHeight(
|
||||
isIPad: isIPad,
|
||||
width: width,
|
||||
isExpandedPhone: isExpandedPhone,
|
||||
isCompactHeightPhone: isCompactHeightPhone
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -27,12 +27,32 @@ enum TypingLayoutMetrics {
|
||||
|
||||
// MARK: - iPad (regular size class) metrics
|
||||
|
||||
static func metrics(isIPad: Bool, width: CGFloat) -> TypingKeyLayoutBuilder.Metrics {
|
||||
TypingSurfaceMetrics.metrics(isIPad: isIPad, width: width)
|
||||
static func metrics(
|
||||
isIPad: Bool,
|
||||
width: CGFloat,
|
||||
isExpandedPhone: Bool = false,
|
||||
isCompactHeightPhone: Bool = false
|
||||
) -> TypingKeyLayoutBuilder.Metrics {
|
||||
TypingSurfaceMetrics.metrics(
|
||||
isIPad: isIPad,
|
||||
width: width,
|
||||
isExpandedPhone: isExpandedPhone,
|
||||
isCompactHeightPhone: isCompactHeightPhone
|
||||
)
|
||||
}
|
||||
|
||||
static func contentHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
|
||||
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
|
||||
static func contentHeight(
|
||||
isIPad: Bool,
|
||||
width: CGFloat,
|
||||
isExpandedPhone: Bool = false,
|
||||
isCompactHeightPhone: Bool = false
|
||||
) -> CGFloat {
|
||||
TypingSurfaceMetrics.contentHeight(
|
||||
isIPad: isIPad,
|
||||
width: width,
|
||||
isExpandedPhone: isExpandedPhone,
|
||||
isCompactHeightPhone: isCompactHeightPhone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +70,18 @@ struct TypingRootView: View {
|
||||
/// 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)
|
||||
static func totalHeight(
|
||||
isIPad: Bool = false,
|
||||
width: CGFloat = 0,
|
||||
isExpandedPhone: Bool = false,
|
||||
isCompactHeightPhone: Bool = false
|
||||
) -> CGFloat {
|
||||
TypingLayoutMetrics.contentHeight(
|
||||
isIPad: isIPad,
|
||||
width: width,
|
||||
isExpandedPhone: isExpandedPhone,
|
||||
isCompactHeightPhone: isCompactHeightPhone
|
||||
)
|
||||
}
|
||||
|
||||
private var palette: ThemePalette {
|
||||
@@ -95,7 +125,9 @@ struct TypingRootView: View {
|
||||
.frame(
|
||||
height: Self.totalHeight(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
)
|
||||
)
|
||||
.background(Color.clear)
|
||||
@@ -569,7 +601,12 @@ struct TypingRootView: View {
|
||||
// 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 metrics = TypingLayoutMetrics.metrics(
|
||||
isIPad: isIPad,
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
)
|
||||
let pageLabel = typing.page == .letters ? "123" : "ABC"
|
||||
let spaceLabel = typing.language == .chinese ? "空格" : "space"
|
||||
let returnLabel: String = {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 识别当前宿主 App 的 bundle ID,供冷启动跳转主 App 后自动跳回。
|
||||
// 目前依赖仅在 DEBUG 编译的私有 arbiter 探针(`OSGHostArbiterProbe`),
|
||||
// Release 构建里识别结果恒为 nil,行为与接入前一致。
|
||||
// 依赖私有 arbiter 探针(`OSGHostArbiterProbe`),Debug / TestFlight / Release 均生效;
|
||||
// 探针装不上(系统 < 26.4、私有符号改名)时识别结果为 nil,退化为“不自动跳回”。
|
||||
// 调试日志与首页测试卡片仍只在 DEBUG 编译。
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
@@ -11,15 +12,16 @@ import OSGKeyboardShared
|
||||
enum HostAppIdentity {
|
||||
/// 最近一次识别到的宿主;识别不到、或宿主是 OSG 自己时为 nil。
|
||||
static var currentBundleId: String? {
|
||||
#if DEBUG
|
||||
guard let bundleId = OSGHostArbiterProbe.lastCapturedBundleId,
|
||||
!HostAppURLRegistry.isOwnBundleId(bundleId) else {
|
||||
return nil
|
||||
}
|
||||
return bundleId
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 键盘弹出时主动让 arbiter 回调一次,避免宿主没变时拿不到最新 ID。
|
||||
static func refreshOnPresentation() {
|
||||
OSGHostArbiterProbe.pingArbiter()
|
||||
}
|
||||
|
||||
/// 跳转主 App(`startflow`)前调用:把当前宿主写进 App Group,主 App 就绪后据此跳回。
|
||||
@@ -39,14 +41,12 @@ enum HostAppIdentity {
|
||||
|
||||
/// 键盘收起时作废识别结果,下次弹出由新回调重新填充。
|
||||
static func invalidate() {
|
||||
#if DEBUG
|
||||
OSGHostArbiterProbe.invalidateCapturedBundleId()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 临时调试:把探针回调写进 App Group,主 App 首页的测试卡片读取展示。
|
||||
/// 调试:把探针回调写进 App Group,主 App 首页的测试卡片读取展示。
|
||||
enum HostBundleProbe {
|
||||
/// 只需安装一次;回调可能来自非主线程,闭包必须是 nonisolated。
|
||||
static func installLogging() {
|
||||
@@ -55,14 +55,13 @@ enum HostBundleProbe {
|
||||
}
|
||||
}
|
||||
|
||||
/// 键盘弹出时记一条标记,再主动触发回调,便于把记录和每次弹出对应起来。
|
||||
static func recordPresentationAndPing() {
|
||||
/// 键盘弹出时记一条标记,便于把记录和每次弹出对应起来(ping 由 `HostAppIdentity` 负责)。
|
||||
static func recordPresentation() {
|
||||
HostBundleProbeLog.append(
|
||||
event: "appear",
|
||||
bundleId: nil,
|
||||
detail: OSGHostArbiterProbe.isInstalled ? "hook=installed" : "hook=missing"
|
||||
)
|
||||
OSGHostArbiterProbe.pingArbiter()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// OSGHostArbiterProbe.h
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 临时调试探针(仅 DEBUG):iOS 26.4+ 通过 _UIKeyboardArbiterClient 回调
|
||||
// 读取宿主 App 的 bundle ID。原理参考 editorss/KeyboardHostBundleID(MIT)。
|
||||
// 宿主识别探针(Debug / TestFlight / Release 均编译):iOS 26.4+ 通过
|
||||
// _UIKeyboardArbiterClient 回调读取宿主 App 的 bundle ID,供冷启动后自动跳回。
|
||||
// 原理参考 editorss/KeyboardHostBundleID(MIT)。
|
||||
// 必须在 +load 安装,否则可能错过键盘首次切换宿主时的回调。
|
||||
// 所有私有符号运行时按字符串查找,任何一步失败都静默退化为“识别不到宿主”。
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
#ifdef DEBUG
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 回调可能在 arbiter 的私有队列上触发,不保证主线程。
|
||||
@@ -34,5 +34,3 @@ typedef void (^OSGHostArbiterCaptureHandler)(NSString *_Nullable bundleId, NSStr
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// OSGHostArbiterProbe.m
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// 临时调试探针(仅 DEBUG),见头文件说明。所有私有符号都在运行时按字符串查找,
|
||||
// 宿主识别探针(所有构建配置均编译),见头文件说明。所有私有符号都在运行时按字符串查找,
|
||||
// 任何一步找不到都静默放弃,并保证原始实现照常执行。
|
||||
|
||||
#import "OSGHostArbiterProbe.h"
|
||||
|
||||
#ifdef DEBUG
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <objc/message.h>
|
||||
#import <objc/runtime.h>
|
||||
@@ -129,5 +127,3 @@ static BOOL OSGAlwaysEnabled(__unused id self, __unused SEL _cmd) {
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#endif
|
||||
|
||||
@@ -22,7 +22,9 @@ struct AIKeyboardView: View {
|
||||
static let compactIPadCircleSize: CGFloat = 44
|
||||
static let sideButtonEdgeInset: CGFloat = 8
|
||||
static let actionClusterMaxWidth: CGFloat = 430
|
||||
static let primaryToSecondaryGap: CGFloat = 25
|
||||
static let surfaceVerticalPadding: CGFloat = 4
|
||||
/// The 3-line live transcript plus a little air above and below it.
|
||||
static let minimumContextHeight: CGFloat = statusViewportHeight + 12
|
||||
static let hotwordHeight: CGFloat = 28
|
||||
static let hotwordMaxWidth: CGFloat = 180
|
||||
static let skillTipMaxWidth: CGFloat = 300
|
||||
@@ -30,12 +32,6 @@ struct AIKeyboardView: View {
|
||||
/// 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
|
||||
static let statusViewportHeight = UIFont.systemFont(ofSize: 15).lineHeight * 3
|
||||
}
|
||||
|
||||
@@ -52,7 +48,8 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Layout preview for `--ai-skills-demo`. Nil keeps production clipboard-window gating.
|
||||
/// Top-bar skill capsules for `--ai-skills-demo` / harness / demo recordings.
|
||||
/// 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?)?
|
||||
@@ -77,7 +74,6 @@ struct AIKeyboardView: View {
|
||||
@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
|
||||
@@ -124,16 +120,11 @@ struct AIKeyboardView: View {
|
||||
.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
|
||||
@@ -152,7 +143,7 @@ struct AIKeyboardView: View {
|
||||
) { _ in
|
||||
guard assistantIsResting else { return }
|
||||
reloadHintPool(resetBag: false)
|
||||
guard !showsClipboardSkills, !showsLongPressCoach else { return }
|
||||
guard !showsLongPressCoach else { return }
|
||||
if reduceMotion, let hint = currentHint,
|
||||
poolCards.contains(where: { $0.id == hint.id }) {
|
||||
return
|
||||
@@ -178,13 +169,17 @@ struct AIKeyboardView: View {
|
||||
contextArea
|
||||
.frame(maxHeight: .infinity)
|
||||
.offset(y: Layout.contextVisualOffset)
|
||||
primaryActionRow.frame(height: Layout.primaryHeight)
|
||||
Color.clear.frame(height: Layout.primaryToSecondaryGap)
|
||||
secondaryActionRow.frame(height: Layout.secondaryHeight)
|
||||
primaryActionRow.frame(height: primaryRowHeight)
|
||||
Color.clear.frame(height: primaryToSecondaryGap)
|
||||
if state.usesVoiceKeyRow {
|
||||
voiceKeyRow.frame(height: voiceKeyRowHeight)
|
||||
} else {
|
||||
secondaryActionRow.frame(height: Layout.secondaryHeight)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, Layout.surfaceVerticalPadding)
|
||||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||||
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
||||
.frame(maxWidth: voiceContentWidthCap)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: resolvedHeight)
|
||||
}
|
||||
@@ -233,9 +228,9 @@ struct AIKeyboardView: View {
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, Layout.surfaceVerticalPadding)
|
||||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||||
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
||||
.frame(maxWidth: voiceContentWidthCap)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: resolvedHeight)
|
||||
}
|
||||
@@ -288,9 +283,9 @@ struct AIKeyboardView: View {
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.accessibilityIdentifier("assistant.replyVariants")
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, Layout.surfaceVerticalPadding)
|
||||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||||
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
||||
.frame(maxWidth: voiceContentWidthCap)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: resolvedHeight)
|
||||
}
|
||||
@@ -348,10 +343,48 @@ struct AIKeyboardView: View {
|
||||
private var resolvedHeight: CGFloat {
|
||||
TypingSurfaceMetrics.contentHeight(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
)
|
||||
}
|
||||
|
||||
private var primaryRowHeight: CGFloat {
|
||||
state.usesCompactHeightPhoneLayoutMetrics ? Layout.compactPrimaryHeight : Layout.primaryHeight
|
||||
}
|
||||
|
||||
/// Gap below the mic. The context slot above it is flexible, so whatever
|
||||
/// this gap doesn't take piles up there; `VoiceMicPlacement` splits the
|
||||
/// free height so the mic sits centred on every shell height.
|
||||
private var primaryToSecondaryGap: CGFloat {
|
||||
let bottomRow = state.usesVoiceKeyRow ? voiceKeyRowHeight : Layout.secondaryHeight
|
||||
let contextAndGap = resolvedHeight
|
||||
- 2 * Layout.surfaceVerticalPadding
|
||||
- Layout.topBarHeight
|
||||
- primaryRowHeight
|
||||
- bottomRow
|
||||
let context = VoiceMicPlacement.contextHeight(
|
||||
contextAndGap: contextAndGap,
|
||||
bottomRowHeight: bottomRow,
|
||||
minimumContextHeight: Layout.minimumContextHeight
|
||||
)
|
||||
return max(0, contextAndGap - context)
|
||||
}
|
||||
|
||||
private var voiceContentWidthCap: CGFloat {
|
||||
KeyboardChromeLayout.voiceContentWidthCap(usesVoiceKeyRow: state.usesVoiceKeyRow)
|
||||
}
|
||||
|
||||
/// Same height as the typing bottom row so the two rows coincide.
|
||||
private var voiceKeyRowHeight: CGFloat {
|
||||
TypingSurfaceMetrics.metrics(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
).bottomRowHeight
|
||||
}
|
||||
|
||||
// MARK: - Top bar
|
||||
|
||||
@ViewBuilder
|
||||
@@ -375,16 +408,9 @@ struct AIKeyboardView: View {
|
||||
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).
|
||||
// Fresh copy (or an OOBE lesson), no AI result yet: the top bar becomes
|
||||
// the only 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(
|
||||
@@ -405,28 +431,34 @@ struct AIKeyboardView: View {
|
||||
/// 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"
|
||||
)
|
||||
leadingScrollRow(skills: restingBarSkills)
|
||||
// An OOBE lesson has no real copy to dismiss; its skill stays put.
|
||||
if !showsOOBEClipboardSkill {
|
||||
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,
|
||||
guard assistantIsResting, !restingBarSkills.isEmpty else { return false }
|
||||
#if DEBUG
|
||||
if Self.debugPreviewSkills?.isEmpty == false { return true }
|
||||
#endif
|
||||
if showsOOBEClipboardSkill { return true }
|
||||
guard state.oobePracticeSession == nil,
|
||||
state.clipboardHistoryEnabled,
|
||||
state.aiServiceAvailable,
|
||||
!state.aiSession.canInsert,
|
||||
!state.aiSession.canSelectReplyVariant,
|
||||
let newest = clipboardHistory.newestEntry,
|
||||
newest.id != state.dismissedClipboardSkillEntryID,
|
||||
!topBarSkills.isEmpty else {
|
||||
newest.id != state.dismissedClipboardSkillEntryID else {
|
||||
return false
|
||||
}
|
||||
return AIHintPool.isClipboardSkillWindowActive(
|
||||
@@ -470,7 +502,7 @@ struct AIKeyboardView: View {
|
||||
) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
// Paste + every applicable skill scroll together; the X stays put.
|
||||
leadingScrollRow
|
||||
leadingScrollRow(skills: topBarSkills)
|
||||
KeyboardCancelButton(
|
||||
action: action,
|
||||
accessibilityLabel: ExtL10n.text(labelKey),
|
||||
@@ -486,9 +518,8 @@ struct AIKeyboardView: View {
|
||||
/// Paste capsule followed by every applicable skill — so Paste scrolls along
|
||||
/// with the skills instead of staying pinned.
|
||||
@ViewBuilder
|
||||
private var leadingScrollRow: some View {
|
||||
private func leadingScrollRow(skills: [AIClipboardSkill]) -> some View {
|
||||
let paste = shouldShowClipboardSuggestion
|
||||
let skills = topBarSkills
|
||||
HStack(spacing: 6) {
|
||||
if !paste {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
@@ -534,6 +565,20 @@ struct AIKeyboardView: View {
|
||||
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
|
||||
}
|
||||
|
||||
/// Capsules for the resting skill bar: the DEBUG preview, the one skill an
|
||||
/// OOBE lesson practises, or the ranked production list.
|
||||
private var restingBarSkills: [AIClipboardSkill] {
|
||||
#if DEBUG
|
||||
if let preview = Self.debugPreviewSkills, !preview.isEmpty {
|
||||
return debugSkillsDismissed ? [] : preview
|
||||
}
|
||||
#endif
|
||||
if let expectedSkillID = oobeExpectedSkillID {
|
||||
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
|
||||
}
|
||||
return topBarSkills
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -572,7 +617,8 @@ struct AIKeyboardView: View {
|
||||
sourceText: newest.text,
|
||||
analysis: snapshot.analysis,
|
||||
uiLanguage: state.uiLanguage,
|
||||
limit: 6
|
||||
limit: 6,
|
||||
translationTargetLocaleId: state.translationTargetLocaleId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -604,9 +650,7 @@ struct AIKeyboardView: View {
|
||||
@ViewBuilder
|
||||
private var contextArea: some View {
|
||||
ZStack {
|
||||
if showsOOBEClipboardSkill, !hasActiveStatus {
|
||||
clipboardSkillPager
|
||||
} else if let onboardingPracticeHint, !hasActiveStatus {
|
||||
if let onboardingPracticeHint, !hasActiveStatus {
|
||||
Text(onboardingPracticeHint)
|
||||
.font(TypeStyle.footnote.weight(.semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
@@ -627,13 +671,6 @@ struct AIKeyboardView: View {
|
||||
.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 hasActiveStatus {
|
||||
@@ -848,74 +885,6 @@ struct AIKeyboardView: View {
|
||||
|
||||
// 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,
|
||||
@@ -937,51 +906,17 @@ struct AIKeyboardView: View {
|
||||
!custom.isEmpty {
|
||||
return custom
|
||||
}
|
||||
// "Translate to <target>" names the user's configured target language.
|
||||
if skill.id == AIClipboardSkillCatalog.translateToTargetID,
|
||||
let title = AIClipboardSkillCatalog.translateToTargetButtonTitle(
|
||||
translationTargetLocaleId: state.translationTargetLocaleId,
|
||||
uiLanguage: state.uiLanguage
|
||||
) {
|
||||
return title
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1018,16 +953,19 @@ struct AIKeyboardView: View {
|
||||
|
||||
private var primaryActionRow: some View {
|
||||
ZStack {
|
||||
HStack {
|
||||
primarySideButton(leading: true)
|
||||
.offset(x: Layout.sideButtonEdgeInset)
|
||||
Spacer(minLength: 0)
|
||||
primarySideButton(leading: false)
|
||||
.offset(x: -Layout.sideButtonEdgeInset)
|
||||
// The key row already carries space / delete, so the mic stands alone.
|
||||
if !state.usesVoiceKeyRow {
|
||||
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)
|
||||
}
|
||||
.opacity(sideButtonsVisible ? 1 : 0)
|
||||
.allowsHitTesting(sideButtonsVisible)
|
||||
.accessibilityHidden(!sideButtonsVisible)
|
||||
|
||||
microphoneButton
|
||||
}
|
||||
@@ -1035,11 +973,16 @@ struct AIKeyboardView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
/// Handedness rule shared by the circle cluster and the key row: space and
|
||||
/// edit share one side, delete and undo the other.
|
||||
private func placesSpaceAndEdit(leading: Bool) -> Bool {
|
||||
let swapped = state.handednessPreference.swapsActionKeys
|
||||
return leading ? swapped : !swapped
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func primarySideButton(leading: Bool) -> some View {
|
||||
let swapped = state.handednessPreference.swapsActionKeys
|
||||
let showsSpace = leading ? swapped : !swapped
|
||||
if showsSpace {
|
||||
if placesSpaceAndEdit(leading: leading) {
|
||||
circleActionButton(
|
||||
systemName: "space",
|
||||
label: ExtL10n.string("keyboard.assistant.space"),
|
||||
@@ -1225,13 +1168,14 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
|
||||
private var primaryButtonWidth: CGFloat {
|
||||
state.layoutWidth > 0 && state.layoutWidth < 350
|
||||
KeyboardChromeLayout.usesCompactVoiceMetrics(width: state.layoutWidth)
|
||||
? Layout.compactPrimaryWidth
|
||||
: Layout.primaryWidth
|
||||
}
|
||||
|
||||
private var primaryButtonHeight: CGFloat {
|
||||
state.layoutWidth > 0 && state.layoutWidth < 350
|
||||
KeyboardChromeLayout.usesCompactVoiceMetrics(width: state.layoutWidth)
|
||||
|| state.usesCompactHeightPhoneLayoutMetrics
|
||||
? Layout.compactPrimaryHeight
|
||||
: Layout.primaryHeight
|
||||
}
|
||||
@@ -1313,38 +1257,17 @@ struct AIKeyboardView: View {
|
||||
.accessibilityHidden(activePipeline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
/// No globe here: switching input modes stays on the typing tab.
|
||||
private func lowerActionCluster(leading: Bool) -> some View {
|
||||
let button = lowerSideButton(leading: leading)
|
||||
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 {
|
||||
if placesSpaceAndEdit(leading: leading) {
|
||||
circleActionButton(
|
||||
systemName: "pencil.line",
|
||||
label: ExtL10n.string("keyboard.edit.apply"),
|
||||
@@ -1422,12 +1345,123 @@ struct AIKeyboardView: View {
|
||||
: Layout.fieldActionWidth
|
||||
}
|
||||
|
||||
// MARK: - Key row (iPad, unfolded iPhone Duo)
|
||||
|
||||
/// `[space][edit][ send ][undo][delete]` for a right-handed setting;
|
||||
/// handedness mirrors the row. Every key calls the same action as its phone
|
||||
/// circle — only placement and key face differ. No globe here: switching
|
||||
/// input modes stays on the typing tab.
|
||||
private var voiceKeyRow: some View {
|
||||
GeometryReader { proxy in
|
||||
let row = VoiceKeyRowLayout.make(rowWidth: proxy.size.width)
|
||||
let height = proxy.size.height
|
||||
ZStack(alignment: .topLeading) {
|
||||
keyRowSlot(row.leadingSide, height: height) { keyRowSideKey(leading: true) }
|
||||
keyRowSlot(row.leadingContext, height: height) { keyRowContextKey(leading: true) }
|
||||
keyRowSlot(row.send, height: height) { keyRowSendKey }
|
||||
keyRowSlot(row.trailingContext, height: height) { keyRowContextKey(leading: false) }
|
||||
keyRowSlot(row.trailingSide, height: height) { keyRowSideKey(leading: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func keyRowSlot<Content: View>(
|
||||
_ slot: VoiceKeyRowLayout.Slot,
|
||||
height: CGFloat,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
content()
|
||||
.frame(width: slot.width, height: height)
|
||||
.position(x: slot.midX, y: height / 2)
|
||||
}
|
||||
|
||||
/// Keys stay in place while dictation / AI runs; they grey out instead of
|
||||
/// hiding so the row never jumps and a held mic cannot hit them.
|
||||
private var keyRowKeysEnabled: Bool {
|
||||
assistantIsResting
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyRowSideKey(leading: Bool) -> some View {
|
||||
if placesSpaceAndEdit(leading: leading) {
|
||||
RectangularToolbarButton(
|
||||
systemName: "space",
|
||||
label: ExtL10n.string("keyboard.assistant.space"),
|
||||
disabled: !keyRowKeysEnabled,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
action: state.insertSpace
|
||||
)
|
||||
.accessibilityIdentifier("assistant.space")
|
||||
} else {
|
||||
RepeatingDeleteButton(
|
||||
disabled: !keyRowKeysEnabled,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
action: state.deleteBackward
|
||||
)
|
||||
.accessibilityIdentifier("assistant.delete")
|
||||
.accessibilityLabel(Text(ExtL10n.string("keyboard.assistant.delete")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo / edit are always shown here and grey out while unavailable.
|
||||
@ViewBuilder
|
||||
private func keyRowContextKey(leading: Bool) -> some View {
|
||||
if placesSpaceAndEdit(leading: leading) {
|
||||
RectangularToolbarButton(
|
||||
systemName: "pencil.line",
|
||||
label: ExtL10n.string("keyboard.edit.apply"),
|
||||
disabled: !state.editAvailable || !keyRowKeysEnabled,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
onPressingChanged: state.setMicTouchActive,
|
||||
action: state.beginEditLastInput
|
||||
)
|
||||
.accessibilityIdentifier("assistant.edit")
|
||||
.accessibilityHidden(!state.editAvailable)
|
||||
} else {
|
||||
RectangularToolbarButton(
|
||||
systemName: "arrow.uturn.backward",
|
||||
label: ExtL10n.string("keyboard.undoA11y"),
|
||||
disabled: !state.undoAvailable || !keyRowKeysEnabled,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
action: state.undoLastInsertion
|
||||
)
|
||||
.accessibilityIdentifier("assistant.undo")
|
||||
.accessibilityHidden(!state.undoAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Same icon, enablement and check-mark confirmation as `fieldActionButton`.
|
||||
private var keyRowSendKey: some View {
|
||||
Button(action: performFieldAction) {
|
||||
Image(systemName: fieldActionSystemImage)
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(fieldActionButtonForeground)
|
||||
}
|
||||
.buttonStyle(
|
||||
NativeKeyboardKeyStyle(
|
||||
fill: fieldActionButtonFill,
|
||||
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
|
||||
border: palette.divider,
|
||||
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
|
||||
)
|
||||
)
|
||||
.disabled(!state.assistantActionAvailable || activePipeline)
|
||||
.opacity(activePipeline ? 0.38 : 1)
|
||||
.accessibilityIdentifier(
|
||||
"assistant.action.\(state.returnKeyRole.assistantActionIdentifier)"
|
||||
)
|
||||
.accessibilityLabel(ExtL10n.text(state.returnKeyRole.titleKey))
|
||||
}
|
||||
|
||||
private var lowerCircleSize: CGFloat {
|
||||
compactIPadLayout ? Layout.compactIPadCircleSize : Layout.circleSize
|
||||
}
|
||||
|
||||
private var compactIPadLayout: Bool {
|
||||
state.usesIPadLayoutMetrics && state.layoutWidth > 0 && state.layoutWidth < 420
|
||||
KeyboardChromeLayout.usesCompactIPadVoiceMetrics(
|
||||
isIPad: state.usesIPadLayoutMetrics,
|
||||
width: state.layoutWidth
|
||||
)
|
||||
}
|
||||
|
||||
private func circleActionButton(
|
||||
@@ -1532,7 +1566,7 @@ struct AIKeyboardView: View {
|
||||
|
||||
private func resetCarousel() {
|
||||
reloadHintPool(resetBag: true)
|
||||
guard !showsClipboardSkills, semanticBadgeContent == nil else { return }
|
||||
guard semanticBadgeContent == nil else { return }
|
||||
showNextHint(animated: false)
|
||||
}
|
||||
|
||||
|
||||
@@ -243,9 +243,7 @@ struct ClipboardHistoryPanelView: View {
|
||||
}
|
||||
|
||||
if history.entries.isEmpty {
|
||||
ExtL10n.text("keyboard.clipboard.panel.empty")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
emptyState
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
@@ -277,6 +275,26 @@ struct ClipboardHistoryPanelView: View {
|
||||
.animation(.easeOut(duration: 0.16), value: showClearConfirmation)
|
||||
}
|
||||
|
||||
/// Icon + title + one-line hint, so an empty panel explains what fills it.
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.font(.system(size: 22, weight: .regular))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.padding(.bottom, 4)
|
||||
.accessibilityHidden(true)
|
||||
ExtL10n.text("keyboard.clipboard.panel.empty")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
ExtL10n.text("keyboard.clipboard.panel.emptyHint")
|
||||
.font(TypeStyle.caption.weight(.regular))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var clearConfirmationOverlay: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "trash")
|
||||
|
||||
@@ -8,6 +8,25 @@ struct LastInputEditView: View {
|
||||
private enum Layout {
|
||||
static let primaryButtonHeight = KeyboardChromeLayout.assistantActionCapsuleHeight
|
||||
static let primaryButtonWidth: CGFloat = primaryButtonHeight * 3
|
||||
static let pagesHeight: CGFloat = 174
|
||||
}
|
||||
|
||||
/// Phone and iPad keep the fixed 281 pt column; the shorter expanded-phone
|
||||
/// and landscape-phone shells take the difference out of the review pages.
|
||||
private var surfaceHeight: CGFloat {
|
||||
guard state.usesExpandedPhoneLayoutMetrics || state.usesCompactHeightPhoneLayoutMetrics else {
|
||||
return KeyboardChromeLayout.totalHeight
|
||||
}
|
||||
return TypingSurfaceMetrics.contentHeight(
|
||||
isIPad: false,
|
||||
width: state.layoutWidth,
|
||||
isExpandedPhone: state.usesExpandedPhoneLayoutMetrics,
|
||||
isCompactHeightPhone: state.usesCompactHeightPhoneLayoutMetrics
|
||||
)
|
||||
}
|
||||
|
||||
private var pagesHeight: CGFloat {
|
||||
Layout.pagesHeight - (KeyboardChromeLayout.totalHeight - surfaceHeight)
|
||||
}
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@@ -32,17 +51,17 @@ struct LastInputEditView: View {
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(height: 174)
|
||||
.frame(height: pagesHeight)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(reviewSwipeGesture)
|
||||
primaryRow.frame(height: 55)
|
||||
}
|
||||
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
|
||||
.frame(maxWidth: KeyboardChromeLayout.voiceContentWidthCap(usesVoiceKeyRow: state.usesVoiceKeyRow))
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: KeyboardChromeLayout.totalHeight)
|
||||
.frame(height: surfaceHeight)
|
||||
.environment(\.themePalette, palette)
|
||||
.onChange(of: state.editSession) { _, newValue in
|
||||
guard let review = newValue.review else {
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"keyboard.clipboard.guide.cta" = "Open Settings";
|
||||
"keyboard.clipboard.panel.title" = "Clipboard";
|
||||
"keyboard.clipboard.panel.empty" = "No history yet";
|
||||
"keyboard.clipboard.panel.emptyHint" = "Text you copy shows up here.";
|
||||
"keyboard.clipboard.panel.fullAccessHint" = "New copies are paused because Allow Full Access is off. Existing history remains available.";
|
||||
"keyboard.clipboard.panel.delete" = "Delete";
|
||||
"keyboard.clipboard.panel.close" = "Close clipboard";
|
||||
@@ -370,6 +371,8 @@
|
||||
"keyboard.ai.skill.createContact" = "New contact";
|
||||
"keyboard.ai.skill.extractConclusions" = "Conclusions";
|
||||
"keyboard.ai.skill.translate" = "Translate";
|
||||
"keyboard.ai.skill.translateToTarget" = "Translate";
|
||||
"keyboard.ai.skill.polishOriginal" = "Polish";
|
||||
"keyboard.ai.skill.acceptInvitation" = "Accept";
|
||||
"keyboard.ai.skill.declineInvitation" = "Decline";
|
||||
"keyboard.ai.skill.acceptTask" = "Acknowledge";
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"keyboard.clipboard.guide.cta" = "去设置开启";
|
||||
"keyboard.clipboard.panel.title" = "剪贴板";
|
||||
"keyboard.clipboard.panel.empty" = "暂无历史记录";
|
||||
"keyboard.clipboard.panel.emptyHint" = "复制的文字会出现在这里。";
|
||||
"keyboard.clipboard.panel.fullAccessHint" = "「允许完全访问」已关闭,新复制内容将暂停采集,已有历史仍可使用。";
|
||||
"keyboard.clipboard.panel.delete" = "删除";
|
||||
"keyboard.clipboard.panel.close" = "关闭剪贴板";
|
||||
@@ -370,6 +371,8 @@
|
||||
"keyboard.ai.skill.createContact" = "新建联系人";
|
||||
"keyboard.ai.skill.extractConclusions" = "提取结论";
|
||||
"keyboard.ai.skill.translate" = "翻译";
|
||||
"keyboard.ai.skill.translateToTarget" = "翻译";
|
||||
"keyboard.ai.skill.polishOriginal" = "优化";
|
||||
"keyboard.ai.skill.acceptInvitation" = "接受邀约";
|
||||
"keyboard.ai.skill.declineInvitation" = "委婉拒绝";
|
||||
"keyboard.ai.skill.acceptTask" = "确认任务";
|
||||
|
||||
Reference in New Issue
Block a user