merge: unify assistant voice and AI workflows
This commit is contained in:
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
|||||||
|
|
||||||
### Version format
|
### Version format
|
||||||
|
|
||||||
The current source-of-truth version is **1.8.0 (build 72)**. Releases use stable SemVer:
|
The current source-of-truth version is **1.8.0 (build 74)**. Releases use stable SemVer:
|
||||||
|
|
||||||
| Field | File | Rule |
|
| Field | File | Rule |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Safe AI insertion**: AI answers insert automatically only while the original input field and cursor context still match; otherwise the keyboard retains the answer for explicit insertion or discard. / **安全 AI 上屏**:仅当原输入框和光标上下文仍一致时自动插入 AI 回答;上下文变化时保留回答,由用户明确插入或丢弃。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Unified assistant keyboard**: merge Voice and AI into one Assistant tab with tap-to-dictate, hold-to-ask-AI, a liquid-glass capsule microphone, contextual hotwords, one-row paged clipboard skills, and shared Send / undo / edit actions; delete, space, undo, and edit remain available while clipboard skills are visible. / **统一助手键盘**:将语音与 AI 合并为一个助手入口,支持轻点听写、长按问 AI、液态玻璃胶囊麦克风、情境热词、单行分页剪贴板技能,以及共用的发送 / 撤销 / 编辑操作;剪贴板技能出现时仍保留删除、空格、撤销与编辑按钮。
|
||||||
|
- **Adaptive field action**: the assistant action now follows the focused field’s current content and Return semantics, refreshes immediately after keyboard-generated edits, and shows Send, Search, Go, Done, Next, newline, and other matching icons. / **自适应输入框动作**:助手动作现根据焦点输入框的当前内容与 Return 语义显示发送、搜索、前往、完成、下一步、换行等对应图标,并在键盘主动编辑后立即刷新。
|
||||||
|
- **Clipboard setup guidance**: replace the Skills permission wall with a compact next-step card that enables history inline, verifies paste access, hides completed steps, and shortens Clipboard settings copy. / **剪贴板设置指引**:技能页权限墙改为紧凑的下一步卡片,可直接开启历史、验证粘贴访问并隐藏已完成步骤,同时精简剪贴板设置文案。
|
||||||
|
- **AI output language**: apply the global translation target to AI answers while letting an explicit language request override it and preserving source-language structured export data. / **AI 输出语言**:AI 回答遵循全局翻译目标,但明确的语言请求优先,结构化导出数据保留源语言。
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Cursor drag pads**: remove blank-area cursor sliding and its Settings toggle; legacy synced values remain decode-compatible. / **光标拖动区**:移除空白区域滑动光标及其设置开关;旧版同步值仍保持解码兼容。
|
||||||
|
|
||||||
## [1.8.0] - 2026-08-14
|
## [1.8.0] - 2026-08-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ struct OSGKeyboardApp: App {
|
|||||||
AIKeyboardDemoView()
|
AIKeyboardDemoView()
|
||||||
} else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
|
} else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
|
||||||
AIClipboardSkillLayoutDemoView()
|
AIClipboardSkillLayoutDemoView()
|
||||||
|
} else if ProcessInfo.processInfo.arguments.contains("--assistant-ui-test") {
|
||||||
|
AssistantKeyboardUITestHarness()
|
||||||
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
|
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
|
||||||
ClipboardHistoryDemoView()
|
ClipboardHistoryDemoView()
|
||||||
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
|
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ enum AppPermissions {
|
|||||||
case restricted
|
case restricted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PasteAccessResult: Equatable {
|
||||||
|
case verified
|
||||||
|
case noTextAvailable
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let pasteAccessVerifiedKey = "clipboard.pasteAccessVerified.v1"
|
||||||
|
|
||||||
static var micStatus: MicStatus {
|
static var micStatus: MicStatus {
|
||||||
switch AVAudioApplication.shared.recordPermission {
|
switch AVAudioApplication.shared.recordPermission {
|
||||||
case .granted: return .granted
|
case .granted: return .granted
|
||||||
@@ -46,6 +54,12 @@ enum AppPermissions {
|
|||||||
micStatus == .granted && speechStatus == .granted
|
micStatus == .granted && speechStatus == .granted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// iOS does not expose the current "Paste from Other Apps" setting.
|
||||||
|
/// This records the last explicit, successful user-initiated read instead.
|
||||||
|
static var hasVerifiedPasteAccess: Bool {
|
||||||
|
UserDefaults.standard.bool(forKey: pasteAccessVerifiedKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Show guided permission pages when any Flow permission is not granted.
|
/// Show guided permission pages when any Flow permission is not granted.
|
||||||
static var needsPermissionGuidance: Bool {
|
static var needsPermissionGuidance: Bool {
|
||||||
micStatus != .granted || speechStatus != .granted
|
micStatus != .granted || speechStatus != .granted
|
||||||
@@ -82,10 +96,15 @@ enum AppPermissions {
|
|||||||
/// and create the app's "Paste from Other Apps" settings entry.
|
/// and create the app's "Paste from Other Apps" settings entry.
|
||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func requestPasteAccess() -> Bool {
|
static func requestPasteAccess() -> PasteAccessResult {
|
||||||
let pasteboard = UIPasteboard.general
|
let pasteboard = UIPasteboard.general
|
||||||
guard pasteboard.hasStrings else { return false }
|
guard pasteboard.hasStrings else { return .noTextAvailable }
|
||||||
return pasteboard.string != nil
|
guard pasteboard.string != nil else {
|
||||||
|
UserDefaults.standard.set(false, forKey: pasteAccessVerifiedKey)
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
UserDefaults.standard.set(true, forKey: pasteAccessVerifiedKey)
|
||||||
|
return .verified
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Home-screen guidance when Flow permissions are missing after onboarding.
|
/// Home-screen guidance when Flow permissions are missing after onboarding.
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ import OSGKeyboardShared
|
|||||||
|
|
||||||
struct AIAgentSkillsView: View {
|
struct AIAgentSkillsView: View {
|
||||||
@Environment(\.themePalette) private var palette
|
@Environment(\.themePalette) private var palette
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@ObservedObject private var config = ProviderConfig.shared
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
|
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
|
||||||
|
|
||||||
@State private var viewingSkill: AIClipboardSkill?
|
@State private var viewingSkill: AIClipboardSkill?
|
||||||
@State private var editingDraft: SkillEditorDraft?
|
@State private var editingDraft: SkillEditorDraft?
|
||||||
@State private var showFullAlert = false
|
@State private var showFullAlert = false
|
||||||
@State private var showClipboardSettings = false
|
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||||
|
@State private var pasteAccessNeedsRecovery = false
|
||||||
|
@State private var showPasteNoTextAlert = false
|
||||||
|
@State private var showPasteAccessSuccess = false
|
||||||
/// True while a skill card is lifted; locks the page scroll like SpringBoard.
|
/// True while a skill card is lifted; locks the page scroll like SpringBoard.
|
||||||
@State private var isReordering = false
|
@State private var isReordering = false
|
||||||
|
|
||||||
@@ -35,8 +39,9 @@ struct AIAgentSkillsView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
CardPageContent(spacing: Spacing.xl) {
|
CardPageContent(spacing: Spacing.xl) {
|
||||||
if !config.clipboardHistoryEnabled {
|
if showsClipboardAccessGuide {
|
||||||
clipboardHistoryBanner
|
clipboardAccessGuide
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
enabledSection
|
enabledSection
|
||||||
if !store.availableSkills.isEmpty {
|
if !store.availableSkills.isEmpty {
|
||||||
@@ -59,9 +64,6 @@ struct AIAgentSkillsView: View {
|
|||||||
.accessibilityLabel(Text("skills.add"))
|
.accessibilityLabel(Text("skills.add"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(isPresented: $showClipboardSettings) {
|
|
||||||
ClipboardSettingsView(config: config)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.sheet(item: $viewingSkill) { skill in
|
.sheet(item: $viewingSkill) { skill in
|
||||||
SkillDetailSheet(
|
SkillDetailSheet(
|
||||||
@@ -90,53 +92,62 @@ struct AIAgentSkillsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(AppL10n.string("skills.full.message", language: config.uiLanguage))
|
Text(AppL10n.string("skills.full.message", language: config.uiLanguage))
|
||||||
}
|
}
|
||||||
.onAppear { store.reload() }
|
.alert(
|
||||||
|
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
|
||||||
|
isPresented: $showPasteNoTextAlert
|
||||||
|
) {
|
||||||
|
Button("common.done") { showPasteNoTextAlert = false }
|
||||||
|
} message: {
|
||||||
|
Text(AppL10n.string("clipboard.paste.noText.message", language: config.uiLanguage))
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
store.reload()
|
||||||
|
refreshPasteAccessState()
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
guard phase == .active else { return }
|
||||||
|
refreshPasteAccessState()
|
||||||
|
pasteAccessNeedsRecovery = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var clipboardHistoryBanner: some View {
|
private var clipboardAccessGuide: some View {
|
||||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
Label {
|
HStack(alignment: .top, spacing: Spacing.sm) {
|
||||||
Text("skills.clipboard.guide.title")
|
Image(systemName: clipboardGuideIcon)
|
||||||
.font(TypeStyle.bodyEmph)
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(clipboardGuideTint)
|
||||||
} icon: {
|
.frame(width: 36, height: 36)
|
||||||
Image(systemName: "clipboard")
|
.background(clipboardGuideTint.opacity(0.12), in: Circle())
|
||||||
.font(.system(size: 16, weight: .semibold))
|
|
||||||
.foregroundStyle(palette.warning)
|
|
||||||
}
|
|
||||||
Text("skills.clipboard.guide.body")
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
Button {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
showClipboardSettings = true
|
Text(clipboardGuideTitle)
|
||||||
} label: {
|
.font(TypeStyle.bodyEmph)
|
||||||
guideRow(
|
.foregroundStyle(palette.textPrimary)
|
||||||
titleKey: "skills.clipboard.guide.openAppSettings",
|
Text(clipboardGuideBody)
|
||||||
systemImage: "slider.horizontal.3",
|
.font(TypeStyle.caption)
|
||||||
trailing: "chevron.right"
|
.foregroundStyle(palette.textSecondary)
|
||||||
)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Divider().background(palette.divider)
|
if !showPasteAccessSuccess {
|
||||||
|
Button(action: performClipboardGuideAction) {
|
||||||
Button {
|
guideRow(
|
||||||
AppPermissions.openSystemSettings()
|
titleKey: clipboardGuideActionTitle,
|
||||||
} label: {
|
systemImage: clipboardGuideActionIcon,
|
||||||
guideRow(
|
trailing: clipboardGuideActionTrailing
|
||||||
titleKey: "skills.clipboard.guide.openSystemSettings",
|
)
|
||||||
systemImage: "gearshape",
|
}
|
||||||
trailing: "arrow.up.right"
|
.buttonStyle(.plain)
|
||||||
)
|
.accessibilityIdentifier(clipboardGuideActionIdentifier)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(Spacing.md)
|
.padding(Spacing.md)
|
||||||
.background(palette.surface, in: skillCardShape)
|
.background(palette.surface, in: skillCardShape)
|
||||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||||
|
.accessibilityIdentifier("skills.clipboard.guide")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func guideRow(
|
private func guideRow(
|
||||||
@@ -157,10 +168,129 @@ struct AIAgentSkillsView: View {
|
|||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, Spacing.xs)
|
.padding(.horizontal, Spacing.sm)
|
||||||
|
.frame(minHeight: 44)
|
||||||
|
.background(
|
||||||
|
palette.surfaceElevated,
|
||||||
|
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||||
|
)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var showsClipboardAccessGuide: Bool {
|
||||||
|
!config.clipboardHistoryEnabled || !pasteAccessVerified || showPasteAccessSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clipboardGuideTitle: LocalizedStringKey {
|
||||||
|
if showPasteAccessSuccess {
|
||||||
|
return "skills.clipboard.guide.success.title"
|
||||||
|
}
|
||||||
|
if !config.clipboardHistoryEnabled {
|
||||||
|
return "skills.clipboard.guide.title"
|
||||||
|
}
|
||||||
|
if pasteAccessNeedsRecovery {
|
||||||
|
return "skills.clipboard.guide.recovery.title"
|
||||||
|
}
|
||||||
|
return "skills.clipboard.guide.verify.title"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clipboardGuideBody: LocalizedStringKey {
|
||||||
|
if showPasteAccessSuccess {
|
||||||
|
return "skills.clipboard.guide.success.body"
|
||||||
|
}
|
||||||
|
if !config.clipboardHistoryEnabled {
|
||||||
|
return "skills.clipboard.guide.body"
|
||||||
|
}
|
||||||
|
if pasteAccessNeedsRecovery {
|
||||||
|
return "skills.clipboard.guide.recovery.body"
|
||||||
|
}
|
||||||
|
return "skills.clipboard.guide.verify.body"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clipboardGuideIcon: String {
|
||||||
|
if showPasteAccessSuccess {
|
||||||
|
return "checkmark"
|
||||||
|
}
|
||||||
|
return pasteAccessNeedsRecovery ? "exclamationmark" : "clipboard"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clipboardGuideTint: Color {
|
||||||
|
pasteAccessNeedsRecovery ? palette.warning : palette.accent
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clipboardGuideActionTitle: LocalizedStringKey {
|
||||||
|
if !config.clipboardHistoryEnabled {
|
||||||
|
return "skills.clipboard.guide.enableHistory"
|
||||||
|
}
|
||||||
|
if pasteAccessNeedsRecovery {
|
||||||
|
return "skills.clipboard.guide.openSystemSettings"
|
||||||
|
}
|
||||||
|
return "skills.clipboard.guide.verify.action"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clipboardGuideActionIcon: String {
|
||||||
|
if !config.clipboardHistoryEnabled {
|
||||||
|
return "clock.arrow.circlepath"
|
||||||
|
}
|
||||||
|
return pasteAccessNeedsRecovery ? "gearshape" : "checkmark.shield"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clipboardGuideActionTrailing: String {
|
||||||
|
pasteAccessNeedsRecovery ? "arrow.up.right" : "arrow.right"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var clipboardGuideActionIdentifier: String {
|
||||||
|
if !config.clipboardHistoryEnabled {
|
||||||
|
return "skills.clipboard.guide.enableHistory"
|
||||||
|
}
|
||||||
|
if pasteAccessNeedsRecovery {
|
||||||
|
return "skills.clipboard.guide.openSystemSettings"
|
||||||
|
}
|
||||||
|
return "skills.clipboard.guide.verifyPaste"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performClipboardGuideAction() {
|
||||||
|
if !config.clipboardHistoryEnabled {
|
||||||
|
withAnimation(Motion.soft) {
|
||||||
|
config.clipboardHistoryEnabled = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pasteAccessNeedsRecovery {
|
||||||
|
AppPermissions.openSystemSettings()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verifyPasteAccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verifyPasteAccess() {
|
||||||
|
switch AppPermissions.requestPasteAccess() {
|
||||||
|
case .verified:
|
||||||
|
withAnimation(Motion.soft) {
|
||||||
|
pasteAccessVerified = true
|
||||||
|
pasteAccessNeedsRecovery = false
|
||||||
|
showPasteAccessSuccess = true
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(700))
|
||||||
|
withAnimation(Motion.soft) {
|
||||||
|
showPasteAccessSuccess = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .noTextAvailable:
|
||||||
|
showPasteNoTextAlert = true
|
||||||
|
case .unavailable:
|
||||||
|
withAnimation(Motion.soft) {
|
||||||
|
pasteAccessVerified = false
|
||||||
|
pasteAccessNeedsRecovery = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshPasteAccessState() {
|
||||||
|
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||||
|
}
|
||||||
|
|
||||||
private var enabledSection: some View {
|
private var enabledSection: some View {
|
||||||
CardSection(
|
CardSection(
|
||||||
title: AppL10n.format(
|
title: AppL10n.format(
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#if DEBUG
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
/// Deterministic host for simulator UI tests of the real assistant keyboard.
|
||||||
|
/// It exercises gesture routing and closed UI states without requiring a
|
||||||
|
/// keyboard-extension process, microphone permission, ASR, or an LLM.
|
||||||
|
struct AssistantKeyboardUITestHarness: View {
|
||||||
|
private enum Scenario: String {
|
||||||
|
case idle
|
||||||
|
case completed
|
||||||
|
case pending
|
||||||
|
case skillFailure
|
||||||
|
case skills
|
||||||
|
case search
|
||||||
|
}
|
||||||
|
|
||||||
|
@StateObject private var state = KeyboardState()
|
||||||
|
@StateObject private var typing = TypingSessionController()
|
||||||
|
@State private var configured = false
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
init() {
|
||||||
|
AIKeyboardView.debugSkipsLongPressCoach = true
|
||||||
|
AIKeyboardView.debugKeepsSkillTip = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private let scenario: Scenario = {
|
||||||
|
let prefix = "--assistant-state="
|
||||||
|
let raw = ProcessInfo.processInfo.arguments
|
||||||
|
.first(where: { $0.hasPrefix(prefix) })?
|
||||||
|
.dropFirst(prefix.count)
|
||||||
|
return raw.flatMap { Scenario(rawValue: String($0)) } ?? .idle
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
AIKeyboardView(
|
||||||
|
state: state,
|
||||||
|
typing: typing,
|
||||||
|
onInsert: { _ in }
|
||||||
|
)
|
||||||
|
.background(backgroundColor)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
configure(width: proxy.size.width)
|
||||||
|
}
|
||||||
|
.onChange(of: proxy.size.width) { _, width in
|
||||||
|
configureLayout(width: width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(backgroundColor.ignoresSafeArea())
|
||||||
|
.onDisappear {
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
AIKeyboardView.debugSkipsLongPressCoach = false
|
||||||
|
AIKeyboardView.debugKeepsSkillTip = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
colorScheme == .dark ? Palette.dark.background : Palette.light.background
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configure(width: CGFloat) {
|
||||||
|
configureLayout(width: width)
|
||||||
|
guard !configured else { return }
|
||||||
|
configured = true
|
||||||
|
|
||||||
|
state.surface = .voice
|
||||||
|
state.phase = .idle
|
||||||
|
state.aiServiceAvailable = true
|
||||||
|
state.micDisabled = false
|
||||||
|
state.returnKeyRole = .send
|
||||||
|
|
||||||
|
let keyboardState = state
|
||||||
|
state.tapMic = { [weak keyboardState] in
|
||||||
|
guard let keyboardState else { return }
|
||||||
|
if case .recording = keyboardState.phase {
|
||||||
|
keyboardState.phase = .idle
|
||||||
|
} else {
|
||||||
|
keyboardState.phase = .recording
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.tapAIMic = { [weak keyboardState] in
|
||||||
|
guard let keyboardState else { return }
|
||||||
|
if keyboardState.aiSession.phase == .listening {
|
||||||
|
if let utteranceID = keyboardState.aiSession.activeUtteranceID {
|
||||||
|
keyboardState.aiSession.beginRecognizing(utteranceID: utteranceID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyboardState.aiSession.enter()
|
||||||
|
let utteranceID = UUID()
|
||||||
|
keyboardState.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||||
|
keyboardState.aiSession.beginListening(utteranceID: utteranceID)
|
||||||
|
}
|
||||||
|
state.performAssistantFieldAction = { [weak keyboardState] in
|
||||||
|
keyboardState?.assistantActionAvailable = false
|
||||||
|
}
|
||||||
|
state.undoLastInsertion = { [weak keyboardState] in
|
||||||
|
keyboardState?.undoAvailable = false
|
||||||
|
}
|
||||||
|
state.beginEditLastInput = { [weak keyboardState] in
|
||||||
|
guard let keyboardState else { return }
|
||||||
|
let reference = EditableInputReference(
|
||||||
|
displayText: "Original dictated input",
|
||||||
|
insertedText: "Original dictated input",
|
||||||
|
postInsertionFingerprint: nil,
|
||||||
|
extensionInstanceID: UUID()
|
||||||
|
)
|
||||||
|
keyboardState.editSession = .listening(EditSessionSource(reference: reference))
|
||||||
|
keyboardState.phase = .recording
|
||||||
|
}
|
||||||
|
state.stopEditListening = { [weak keyboardState] in
|
||||||
|
guard let keyboardState,
|
||||||
|
let source = keyboardState.editSession.source else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyboardState.editSession = .review(
|
||||||
|
EditReview(
|
||||||
|
source: source,
|
||||||
|
resultText: "Edited dictated input",
|
||||||
|
utteranceID: UUID()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
keyboardState.phase = .processing
|
||||||
|
}
|
||||||
|
state.confirmEditResult = { [weak keyboardState] in
|
||||||
|
keyboardState?.editSession = .inactive
|
||||||
|
keyboardState?.phase = .idle
|
||||||
|
}
|
||||||
|
state.submitAIHint = { [weak keyboardState] _ in
|
||||||
|
guard let keyboardState else { return }
|
||||||
|
let utteranceID = UUID()
|
||||||
|
keyboardState.aiSession.enter()
|
||||||
|
keyboardState.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||||
|
keyboardState.aiSession.beginGenerating(
|
||||||
|
question: "Deterministic hint",
|
||||||
|
utteranceID: utteranceID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch scenario {
|
||||||
|
case .idle:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
case .completed:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
state.undoAvailable = true
|
||||||
|
state.editAvailable = true
|
||||||
|
state.assistantActionAvailable = true
|
||||||
|
case .pending:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
let utteranceID = UUID()
|
||||||
|
state.aiSession.enter()
|
||||||
|
state.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||||
|
state.aiSession.receiveAnswer(
|
||||||
|
"A retained answer that requires explicit insertion.",
|
||||||
|
utteranceID: utteranceID
|
||||||
|
)
|
||||||
|
state.confirmPendingAIAnswer = { [weak keyboardState] in
|
||||||
|
guard let keyboardState else { return }
|
||||||
|
keyboardState.aiSession.markAnswerInserted(offersSend: true)
|
||||||
|
keyboardState.undoAvailable = true
|
||||||
|
keyboardState.editAvailable = true
|
||||||
|
keyboardState.assistantActionAvailable = true
|
||||||
|
}
|
||||||
|
state.discardPendingAIAnswer = { [weak keyboardState] in
|
||||||
|
keyboardState?.aiSession.discardReadyAnswer()
|
||||||
|
}
|
||||||
|
case .skillFailure:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
state.skillTipText = "Skill failed"
|
||||||
|
case .skills:
|
||||||
|
AIKeyboardView.debugPreviewSkills = AIClipboardSkillCatalog.catalog
|
||||||
|
state.undoAvailable = true
|
||||||
|
state.editAvailable = true
|
||||||
|
case .search:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
state.returnKeyRole = .search
|
||||||
|
state.assistantActionAvailable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureLayout(width: CGFloat) {
|
||||||
|
let isIPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||||
|
state.layoutWidth = width
|
||||||
|
state.usesIPadLayoutMetrics = isIPad
|
||||||
|
state.showsSystemGlobeKey = isIPad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -232,23 +232,6 @@ struct RememberLastSurfaceToggleRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cursor drag navigation toggle
|
|
||||||
|
|
||||||
struct CursorDragNavigationToggleRow: View {
|
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
|
||||||
@Binding var isOn: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Toggle(isOn: $isOn) {
|
|
||||||
Text("settings.cursorDragNavigation.title")
|
|
||||||
.font(TypeStyle.body)
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
}
|
|
||||||
.tint(palette.accent)
|
|
||||||
.settingsListRow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Menu picker row (generic)
|
// MARK: - Menu picker row (generic)
|
||||||
|
|
||||||
struct SettingsMenuPickerRow: View {
|
struct SettingsMenuPickerRow: View {
|
||||||
|
|||||||
@@ -259,10 +259,6 @@ struct GeneralSettingsView: View {
|
|||||||
set: { config.keyboardHapticIntensity = $0 }
|
set: { config.keyboardHapticIntensity = $0 }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Divider().background(palette.divider)
|
|
||||||
CursorDragNavigationToggleRow(
|
|
||||||
isOn: $config.cursorDragNavigationEnabled
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.surfaceCard()
|
.surfaceCard()
|
||||||
}
|
}
|
||||||
@@ -310,9 +306,13 @@ struct AIAgentSettingsView: View {
|
|||||||
|
|
||||||
struct ClipboardSettingsView: View {
|
struct ClipboardSettingsView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@ObservedObject var config: ProviderConfig
|
@ObservedObject var config: ProviderConfig
|
||||||
@ObservedObject private var history = ClipboardHistoryStore.shared
|
@ObservedObject private var history = ClipboardHistoryStore.shared
|
||||||
@State private var showClearConfirmation = false
|
@State private var showClearConfirmation = false
|
||||||
|
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||||
|
@State private var pasteAccessNeedsRecovery = false
|
||||||
|
@State private var showPasteNoTextAlert = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -356,40 +356,58 @@ struct ClipboardSettingsView: View {
|
|||||||
// "Paste from Other Apps" permission to Allow.
|
// "Paste from Other Apps" permission to Allow.
|
||||||
CardSection("settings.clipboard.paste.section") {
|
CardSection("settings.clipboard.paste.section") {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text("settings.clipboard.paste.body")
|
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||||
.font(.footnote)
|
Text("settings.clipboard.paste.body")
|
||||||
.foregroundStyle(palette.textSecondary)
|
.font(.footnote)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.padding(.horizontal, 16)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.vertical, 12)
|
if pasteAccessVerified {
|
||||||
|
Label(
|
||||||
Divider().background(palette.divider)
|
"settings.clipboard.paste.verified",
|
||||||
|
systemImage: "checkmark.circle.fill"
|
||||||
Button {
|
|
||||||
AppPermissions.requestPasteAccess()
|
|
||||||
} label: {
|
|
||||||
SettingsNavigationRow(
|
|
||||||
titleText: AppL10n.string(
|
|
||||||
"settings.clipboard.paste.request",
|
|
||||||
language: config.uiLanguage
|
|
||||||
)
|
)
|
||||||
)
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
.fixedSize()
|
||||||
|
.accessibilityIdentifier("settings.clipboard.paste.verified")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
Divider().background(palette.divider)
|
if !pasteAccessVerified {
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
AppPermissions.openSystemSettings()
|
verifyPasteAccess()
|
||||||
} label: {
|
} label: {
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
titleText: AppL10n.string(
|
titleText: AppL10n.string(
|
||||||
"settings.clipboard.paste.open",
|
"settings.clipboard.paste.request",
|
||||||
language: config.uiLanguage
|
language: config.uiLanguage
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("settings.clipboard.paste.verify")
|
||||||
|
|
||||||
|
if pasteAccessNeedsRecovery {
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
AppPermissions.openSystemSettings()
|
||||||
|
} label: {
|
||||||
|
SettingsNavigationRow(
|
||||||
|
titleText: AppL10n.string(
|
||||||
|
"settings.clipboard.paste.open",
|
||||||
|
language: config.uiLanguage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("settings.clipboard.paste.openSettings")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
.surfaceCard()
|
.surfaceCard()
|
||||||
}
|
}
|
||||||
@@ -441,8 +459,22 @@ struct ClipboardSettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("settings.clipboard.clear.message")
|
Text("settings.clipboard.clear.message")
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
|
||||||
|
isPresented: $showPasteNoTextAlert
|
||||||
|
) {
|
||||||
|
Button("common.done") { showPasteNoTextAlert = false }
|
||||||
|
} message: {
|
||||||
|
Text(AppL10n.string("clipboard.paste.noText.message", language: config.uiLanguage))
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
history.reload()
|
history.reload()
|
||||||
|
refreshPasteAccessState()
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
guard phase == .active else { return }
|
||||||
|
refreshPasteAccessState()
|
||||||
|
pasteAccessNeedsRecovery = false
|
||||||
}
|
}
|
||||||
.onChange(of: config.clipboardHistoryEnabled) { _, enabled in
|
.onChange(of: config.clipboardHistoryEnabled) { _, enabled in
|
||||||
if !enabled {
|
if !enabled {
|
||||||
@@ -457,6 +489,27 @@ struct ClipboardSettingsView: View {
|
|||||||
set: { config.clipboardCandidateBarEnabled = $0 }
|
set: { config.clipboardCandidateBarEnabled = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func verifyPasteAccess() {
|
||||||
|
switch AppPermissions.requestPasteAccess() {
|
||||||
|
case .verified:
|
||||||
|
withAnimation(Motion.quick) {
|
||||||
|
pasteAccessVerified = true
|
||||||
|
pasteAccessNeedsRecovery = false
|
||||||
|
}
|
||||||
|
case .noTextAvailable:
|
||||||
|
showPasteNoTextAlert = true
|
||||||
|
case .unavailable:
|
||||||
|
withAnimation(Motion.quick) {
|
||||||
|
pasteAccessVerified = false
|
||||||
|
pasteAccessNeedsRecovery = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshPasteAccessState() {
|
||||||
|
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - About
|
// MARK: - About
|
||||||
|
|||||||
@@ -205,15 +205,18 @@
|
|||||||
"settings.clipboard.subtitle.on" = "On";
|
"settings.clipboard.subtitle.on" = "On";
|
||||||
"settings.clipboard.subtitle.off" = "Off";
|
"settings.clipboard.subtitle.off" = "Off";
|
||||||
"settings.clipboard.history.title" = "History";
|
"settings.clipboard.history.title" = "History";
|
||||||
"settings.clipboard.history.footer" = "Off by default. Captures text copied on this device or through Universal Clipboard and keeps up to 15 items in this device’s App Group. AI mode can suggest clipboard-related prompts for about 30 seconds after a copy.";
|
"settings.clipboard.history.footer" = "Keeps up to 15 copied text items on this device. Off by default.";
|
||||||
"settings.clipboard.candidate.title" = "Suggestion strip";
|
"settings.clipboard.candidate.title" = "Suggestion strip";
|
||||||
"settings.clipboard.candidate.footer" = "Shows the newest copy above the keys for one-tap insert.";
|
"settings.clipboard.candidate.footer" = "Shows your latest copy above the keyboard. Tap to insert.";
|
||||||
"settings.clipboard.paste.section" = "System access";
|
"settings.clipboard.paste.section" = "System access";
|
||||||
"settings.clipboard.paste.body" = "Copy some text, tap Request Paste Access, and allow access. iOS will then create the Paste from Other Apps setting, where you can select Allow.";
|
"settings.clipboard.paste.body" = "Allows clipboard skills to read text you copy.";
|
||||||
"settings.clipboard.paste.request" = "Request Paste Access";
|
"settings.clipboard.paste.request" = "Verify Paste Access";
|
||||||
|
"settings.clipboard.paste.verified" = "Verified";
|
||||||
"settings.clipboard.paste.open" = "Open iOS Settings";
|
"settings.clipboard.paste.open" = "Open iOS Settings";
|
||||||
"settings.clipboard.storage.section" = "Local history";
|
"settings.clipboard.storage.section" = "Local history";
|
||||||
"settings.clipboard.storage.body" = "Turning History off stops capture and the suggestion strip but keeps saved items. Clipboard history does not sync through iCloud and is never sent to AI automatically. After you insert it, active polish may include it as context for your configured provider. Sensitive filtering is conservative and cannot detect every password.";
|
"settings.clipboard.storage.body" = "History stays on this device, does not sync through iCloud, and is never sent to AI automatically. Turning History off keeps saved items.";
|
||||||
|
"clipboard.paste.noText.title" = "Copy text first";
|
||||||
|
"clipboard.paste.noText.message" = "Copy a short piece of text, then try again.";
|
||||||
"settings.clipboard.clear.button" = "Clear clipboard history";
|
"settings.clipboard.clear.button" = "Clear clipboard history";
|
||||||
"settings.clipboard.clear.title" = "Clear clipboard history?";
|
"settings.clipboard.clear.title" = "Clear clipboard history?";
|
||||||
"settings.clipboard.clear.message" = "This permanently removes all saved clipboard items from this device. This cannot be undone.";
|
"settings.clipboard.clear.message" = "This permanently removes all saved clipboard items from this device. This cannot be undone.";
|
||||||
@@ -440,9 +443,16 @@
|
|||||||
"skills.enabled.section" = "In use (%d/%d)";
|
"skills.enabled.section" = "In use (%d/%d)";
|
||||||
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
||||||
"skills.available.section" = "Available";
|
"skills.available.section" = "Available";
|
||||||
"skills.clipboard.guide.title" = "Clipboard access needed";
|
"skills.clipboard.guide.title" = "Enable clipboard skills";
|
||||||
"skills.clipboard.guide.body" = "Turn on Clipboard History in the app, then allow paste access in iOS Settings, so these skills can run after you copy text.";
|
"skills.clipboard.guide.body" = "Copy text to summarize, translate, or extract actions from it.";
|
||||||
"skills.clipboard.guide.openAppSettings" = "Open Clipboard settings";
|
"skills.clipboard.guide.enableHistory" = "Turn On Clipboard History";
|
||||||
|
"skills.clipboard.guide.verify.title" = "One more step";
|
||||||
|
"skills.clipboard.guide.verify.body" = "Allow OSGKeyboard to read text you copy.";
|
||||||
|
"skills.clipboard.guide.verify.action" = "Verify Paste Access";
|
||||||
|
"skills.clipboard.guide.recovery.title" = "Paste access is off";
|
||||||
|
"skills.clipboard.guide.recovery.body" = "Allow Paste from Other Apps in iOS Settings, then return to verify.";
|
||||||
|
"skills.clipboard.guide.success.title" = "Clipboard skills are ready";
|
||||||
|
"skills.clipboard.guide.success.body" = "Copy text to use them from the keyboard.";
|
||||||
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
|
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
|
||||||
"skills.detail.title" = "Skill";
|
"skills.detail.title" = "Skill";
|
||||||
"skills.badge.default" = "Default skill";
|
"skills.badge.default" = "Default skill";
|
||||||
|
|||||||
@@ -205,15 +205,18 @@
|
|||||||
"settings.clipboard.subtitle.on" = "已开启";
|
"settings.clipboard.subtitle.on" = "已开启";
|
||||||
"settings.clipboard.subtitle.off" = "已关闭";
|
"settings.clipboard.subtitle.off" = "已关闭";
|
||||||
"settings.clipboard.history.title" = "历史记录";
|
"settings.clipboard.history.title" = "历史记录";
|
||||||
"settings.clipboard.history.footer" = "默认关闭。开启后采集本机或通用剪贴板复制的文本,并在本机 App Group 中保存最近 15 条;复制约 30 秒内,AI 模式也可展示剪贴板相关建议。";
|
"settings.clipboard.history.footer" = "在本机保存最近 15 条复制文字,默认关闭。";
|
||||||
"settings.clipboard.candidate.title" = "建议条";
|
"settings.clipboard.candidate.title" = "建议条";
|
||||||
"settings.clipboard.candidate.footer" = "最新复制显示在键盘上方,点一下即可插入。";
|
"settings.clipboard.candidate.footer" = "在键盘上方显示最新复制,轻点即可插入。";
|
||||||
"settings.clipboard.paste.section" = "系统授权";
|
"settings.clipboard.paste.section" = "系统授权";
|
||||||
"settings.clipboard.paste.body" = "先复制一段文字,再点「请求粘贴权限」并允许访问;iOS 随后会生成「从其他 App 粘贴」设置项,可将其设为「允许」。";
|
"settings.clipboard.paste.body" = "允许剪贴板技能读取你主动复制的文字。";
|
||||||
"settings.clipboard.paste.request" = "请求粘贴权限";
|
"settings.clipboard.paste.request" = "验证粘贴访问";
|
||||||
|
"settings.clipboard.paste.verified" = "已验证";
|
||||||
"settings.clipboard.paste.open" = "打开系统设置";
|
"settings.clipboard.paste.open" = "打开系统设置";
|
||||||
"settings.clipboard.storage.section" = "本机历史";
|
"settings.clipboard.storage.section" = "本机历史";
|
||||||
"settings.clipboard.storage.body" = "关闭「历史记录」只会停止采集并关闭建议条,已有记录仍会保留。剪贴板历史不经 iCloud 同步,也不会自动发送给 AI;插入后若主动使用润色,内容可能作为上下文发送给你配置的服务商。敏感内容过滤采用保守规则,无法识别所有密码。";
|
"settings.clipboard.storage.body" = "历史仅保存在本机,不经 iCloud 同步,也不会自动发送给 AI。关闭历史不会删除已有记录。";
|
||||||
|
"clipboard.paste.noText.title" = "请先复制文字";
|
||||||
|
"clipboard.paste.noText.message" = "复制一小段文字后,再试一次。";
|
||||||
"settings.clipboard.clear.button" = "清空剪贴板历史";
|
"settings.clipboard.clear.button" = "清空剪贴板历史";
|
||||||
"settings.clipboard.clear.title" = "清空剪贴板历史?";
|
"settings.clipboard.clear.title" = "清空剪贴板历史?";
|
||||||
"settings.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
|
"settings.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
|
||||||
@@ -439,9 +442,16 @@
|
|||||||
"skills.enabled.section" = "使用中(%d/%d)";
|
"skills.enabled.section" = "使用中(%d/%d)";
|
||||||
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
||||||
"skills.available.section" = "可添加";
|
"skills.available.section" = "可添加";
|
||||||
"skills.clipboard.guide.title" = "需要剪贴板权限";
|
"skills.clipboard.guide.title" = "启用剪贴板技能";
|
||||||
"skills.clipboard.guide.body" = "请先在 App 里打开剪贴板历史,再到系统设置中允许粘贴授权,复制后才能使用这些技能。";
|
"skills.clipboard.guide.body" = "复制文字后,可直接总结、翻译或提取事项。";
|
||||||
"skills.clipboard.guide.openAppSettings" = "打开剪贴板设置";
|
"skills.clipboard.guide.enableHistory" = "开启历史记录";
|
||||||
|
"skills.clipboard.guide.verify.title" = "还差一步";
|
||||||
|
"skills.clipboard.guide.verify.body" = "允许 OSGKeyboard 读取你主动复制的文字。";
|
||||||
|
"skills.clipboard.guide.verify.action" = "验证粘贴访问";
|
||||||
|
"skills.clipboard.guide.recovery.title" = "粘贴访问未开启";
|
||||||
|
"skills.clipboard.guide.recovery.body" = "在系统设置中允许「从其他 App 粘贴」,返回后再验证。";
|
||||||
|
"skills.clipboard.guide.success.title" = "剪贴板技能已就绪";
|
||||||
|
"skills.clipboard.guide.success.body" = "复制文字后,即可从键盘使用这些技能。";
|
||||||
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
|
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
|
||||||
"skills.detail.title" = "技能";
|
"skills.detail.title" = "技能";
|
||||||
"skills.badge.default" = "默认技能";
|
"skills.badge.default" = "默认技能";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// OSGKeyboard · Keyboard Extension
|
// OSGKeyboard · Keyboard Extension
|
||||||
//
|
//
|
||||||
// Principal class for the Custom Keyboard Extension. Hosts a single
|
// Principal class for the Custom Keyboard Extension. Hosts a single
|
||||||
// SwiftUI tree (`KeyboardRootView`) and wires Flow voice input:
|
// SwiftUI tree (`KeyboardSurfaceRoot`) and wires Flow voice / AI input:
|
||||||
//
|
//
|
||||||
// host app Flow session ──► App Group transcript ──► insertText
|
// host app Flow session ──► App Group transcript ──► insertText
|
||||||
//
|
//
|
||||||
@@ -63,6 +63,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
/// changes instead of every pass.
|
/// changes instead of every pass.
|
||||||
private var lastLoggedLayoutSnapshot: String?
|
private var lastLoggedLayoutSnapshot: String?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
/// Coalesces host-document refreshes after mutations issued by this keyboard.
|
||||||
|
private var assistantFieldActionRefreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var editHintScheduler: EditHintScheduler!
|
private var editHintScheduler: EditHintScheduler!
|
||||||
private var textInserter: KeyboardTextInserter!
|
private var textInserter: KeyboardTextInserter!
|
||||||
@@ -71,10 +73,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
private var aiKeyboardCoordinator: AIKeyboardCoordinator!
|
private var aiKeyboardCoordinator: AIKeyboardCoordinator!
|
||||||
private var clipboardCapture: ClipboardCaptureCoordinator!
|
private var clipboardCapture: ClipboardCaptureCoordinator!
|
||||||
private var configSync: KeyboardConfigSync!
|
private var configSync: KeyboardConfigSync!
|
||||||
/// UIKit may synchronously lay out the view during `viewDidLoad`.
|
|
||||||
/// Keep this optional so an early layout pass is harmless.
|
|
||||||
private var cursorDrag: CursorDragController?
|
|
||||||
|
|
||||||
/// iPad-scale keys require both an iPad host and regular horizontal space.
|
/// iPad-scale keys require both an iPad host and regular horizontal space.
|
||||||
/// This keeps compact iPad multitasking on phone metrics and prevents wide
|
/// This keeps compact iPad multitasking on phone metrics and prevents wide
|
||||||
/// iPhones from being mistaken for iPads.
|
/// iPhones from being mistaken for iPads.
|
||||||
@@ -170,6 +168,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
assistantFieldActionRefreshTask?.cancel()
|
||||||
|
assistantFieldActionRefreshTask = nil
|
||||||
clipboardCapture?.keyboardWillDisappear()
|
clipboardCapture?.keyboardWillDisappear()
|
||||||
// Presentation-scoped hints must never survive a reused extension
|
// Presentation-scoped hints must never survive a reused extension
|
||||||
// controller, including an active Flow handoff.
|
// controller, including an active Flow handoff.
|
||||||
@@ -191,10 +191,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
if state.surface == .typing {
|
if state.surface == .typing {
|
||||||
TypingInputConfiguration.persistLastTypingLanguage(typingSession.language)
|
TypingInputConfiguration.persistLastTypingLanguage(typingSession.language)
|
||||||
}
|
}
|
||||||
if state.surface == .ai {
|
if state.aiSession.isActive {
|
||||||
// AI context never survives a keyboard presentation, but the
|
// Unified assistant conversations never survive a keyboard presentation.
|
||||||
// selected surface itself is restored on the next open.
|
|
||||||
TypingInputConfiguration.persistLastSurface(.ai)
|
|
||||||
aiKeyboardCoordinator.leave()
|
aiKeyboardCoordinator.leave()
|
||||||
}
|
}
|
||||||
if !preserve {
|
if !preserve {
|
||||||
@@ -280,16 +278,16 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state: state,
|
state: state,
|
||||||
host: WhatsNewDemoDriver.HostHooks(
|
host: WhatsNewDemoDriver.HostHooks(
|
||||||
insertText: { [weak self] text in
|
insertText: { [weak self] text in
|
||||||
self?.textDocumentProxy.insertText(text)
|
self?.insertTextIntoDocument(text)
|
||||||
},
|
},
|
||||||
deleteBackward: { [weak self] in
|
deleteBackward: { [weak self] in
|
||||||
self?.textDocumentProxy.deleteBackward()
|
self?.deleteBackwardFromDocument()
|
||||||
},
|
},
|
||||||
contextBeforeInput: { [weak self] in
|
contextBeforeInput: { [weak self] in
|
||||||
self?.textDocumentProxy.documentContextBeforeInput
|
self?.textDocumentProxy.documentContextBeforeInput
|
||||||
},
|
},
|
||||||
performReturn: { [weak self] in
|
performReturn: { [weak self] in
|
||||||
self?.textDocumentProxy.insertText("\n")
|
self?.performDocumentReturn()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -310,6 +308,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func selectionDidChange(_ textInput: UITextInput?) {
|
public override func selectionDidChange(_ textInput: UITextInput?) {
|
||||||
super.selectionDidChange(textInput)
|
super.selectionDidChange(textInput)
|
||||||
|
refreshReturnKeyRole()
|
||||||
typingSession.synchronizeEnglishDocumentContext(caretMoved: true)
|
typingSession.synchronizeEnglishDocumentContext(caretMoved: true)
|
||||||
textInserter?.refreshEditingAvailability()
|
textInserter?.refreshEditingAvailability()
|
||||||
lastInputEditCoordinator?.refreshContext()
|
lastInputEditCoordinator?.refreshContext()
|
||||||
@@ -353,7 +352,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
// portrait→landscape resize. `refreshLayoutMode` no-ops unless the
|
// portrait→landscape resize. `refreshLayoutMode` no-ops unless the
|
||||||
// layout bucket actually changed, so this cannot loop.
|
// layout bucket actually changed, so this cannot loop.
|
||||||
refreshLayoutMode()
|
refreshLayoutMode()
|
||||||
cursorDrag?.layoutChrome()
|
|
||||||
enforcePresentedKeyboardHeightIfNeeded()
|
enforcePresentedKeyboardHeightIfNeeded()
|
||||||
logLayoutSnapshotIfChanged()
|
logLayoutSnapshotIfChanged()
|
||||||
}
|
}
|
||||||
@@ -364,8 +362,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
editHintScheduler = EditHintScheduler(state: state)
|
editHintScheduler = EditHintScheduler(state: state)
|
||||||
textInserter = KeyboardTextInserter(
|
textInserter = KeyboardTextInserter(
|
||||||
state: state,
|
state: state,
|
||||||
insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) },
|
insertText: { [weak self] text in self?.insertTextIntoDocument(text) },
|
||||||
deleteBackward: { [weak self] in self?.textDocumentProxy.deleteBackward() },
|
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() },
|
||||||
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
||||||
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
|
||||||
selectedText: { [weak self] in self?.textDocumentProxy.selectedText },
|
selectedText: { [weak self] in self?.textDocumentProxy.selectedText },
|
||||||
@@ -427,7 +425,10 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
self?.textInserter.insertAIAnswer(answer) ?? false
|
self?.textInserter.insertAIAnswer(answer) ?? false
|
||||||
},
|
},
|
||||||
performReturn: { [weak self] in
|
performReturn: { [weak self] in
|
||||||
self?.textDocumentProxy.insertText("\n")
|
self?.performDocumentReturn()
|
||||||
|
},
|
||||||
|
captureInsertionFingerprint: { [weak self] in
|
||||||
|
self?.captureFieldContext().deliveryFingerprint
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
clipboardCapture = ClipboardCaptureCoordinator(state: state)
|
clipboardCapture = ClipboardCaptureCoordinator(state: state)
|
||||||
@@ -474,13 +475,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
self?.state.aiSession.cancelCurrentWork()
|
self?.state.aiSession.cancelCurrentWork()
|
||||||
}
|
}
|
||||||
_ = textInserter.recoverPendingEditTransactionIfNeeded()
|
_ = textInserter.recoverPendingEditTransactionIfNeeded()
|
||||||
|
|
||||||
cursorDrag = CursorDragController(
|
|
||||||
state: state,
|
|
||||||
adjustTextPosition: { [weak self] offset in
|
|
||||||
self?.textDocumentProxy.adjustTextPosition(byCharacterOffset: offset)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Wiring
|
// MARK: - Wiring
|
||||||
@@ -513,8 +507,14 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.cancelAIInput = { [weak self] in
|
state.cancelAIInput = { [weak self] in
|
||||||
self?.aiKeyboardCoordinator.cancel()
|
self?.aiKeyboardCoordinator.cancel()
|
||||||
}
|
}
|
||||||
state.sendAIAnswer = { [weak self] in
|
state.confirmPendingAIAnswer = { [weak self] in
|
||||||
self?.aiKeyboardCoordinator.sendLatestAnswer()
|
self?.aiKeyboardCoordinator.confirmPendingAnswer()
|
||||||
|
}
|
||||||
|
state.discardPendingAIAnswer = { [weak self] in
|
||||||
|
self?.aiKeyboardCoordinator.discardPendingAnswer()
|
||||||
|
}
|
||||||
|
state.performAssistantFieldAction = { [weak self] in
|
||||||
|
self?.aiKeyboardCoordinator.performCurrentFieldAction()
|
||||||
}
|
}
|
||||||
state.submitAIHint = { [weak self] card in
|
state.submitAIHint = { [weak self] card in
|
||||||
self?.aiKeyboardCoordinator.submitHintCard(card)
|
self?.aiKeyboardCoordinator.submitHintCard(card)
|
||||||
@@ -567,23 +567,15 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.setEngineMode = { [weak self] m in self?.configSync.persistEngineMode(m) }
|
state.setEngineMode = { [weak self] m in self?.configSync.persistEngineMode(m) }
|
||||||
state.setTranslationTargetLocaleId = { [weak self] id in
|
state.setTranslationTargetLocaleId = { [weak self] id in
|
||||||
self?.configSync.persistTranslationTargetLocaleId(id)
|
self?.configSync.persistTranslationTargetLocaleId(id)
|
||||||
|
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
|
||||||
}
|
}
|
||||||
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
state.insertNewline = { [weak self] in self?.insertTextIntoDocument("\n") }
|
||||||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
state.insertSpace = { [weak self] in self?.insertTextIntoDocument(" ") }
|
||||||
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
|
state.deleteBackward = { [weak self] in self?.deleteBackwardFromDocument() }
|
||||||
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
||||||
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
||||||
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
||||||
state.cutSelection = { [weak self] in self?.textInserter.cutSelection() }
|
state.cutSelection = { [weak self] in self?.textInserter.cutSelection() }
|
||||||
state.moveCursorHorizontal = { [weak self] steps in
|
|
||||||
self?.cursorDrag?.moveCursorHorizontally(by: steps)
|
|
||||||
}
|
|
||||||
state.moveCursorVertical = { [weak self] steps in
|
|
||||||
self?.cursorDrag?.moveCursorVertically(by: steps)
|
|
||||||
}
|
|
||||||
state.setCursorDragActive = { [weak self] active in
|
|
||||||
self?.cursorDrag?.setCursorDragActive(active)
|
|
||||||
}
|
|
||||||
state.setSurface = { [weak self] surface in
|
state.setSurface = { [weak self] surface in
|
||||||
self?.applySurface(surface)
|
self?.applySurface(surface)
|
||||||
}
|
}
|
||||||
@@ -608,7 +600,10 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applySurface(_ surface: State.Surface) {
|
private func applySurface(_ requestedSurface: State.Surface) {
|
||||||
|
// `.ai` is retained only to decode preferences written by older builds.
|
||||||
|
// The product now has one unified assistant surface.
|
||||||
|
let surface: State.Surface = requestedSurface == .ai ? .voice : requestedSurface
|
||||||
if surface == .typing, state.locksTypingSurface {
|
if surface == .typing, state.locksTypingSurface {
|
||||||
OSGDiag.log("applySurface blocked typing (locksTypingSurface)", category: "boot")
|
OSGDiag.log("applySurface blocked typing (locksTypingSurface)", category: "boot")
|
||||||
return
|
return
|
||||||
@@ -618,9 +613,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard state.surface != surface else {
|
guard state.surface != surface else {
|
||||||
if surface == .ai {
|
|
||||||
aiKeyboardCoordinator.enterIfNeeded()
|
|
||||||
}
|
|
||||||
refreshKeyboardHeight()
|
refreshKeyboardHeight()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -628,10 +620,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
"applySurface \(state.surface.rawValue) → \(surface.rawValue) \(OSGDiag.memoryTag())",
|
"applySurface \(state.surface.rawValue) → \(surface.rawValue) \(OSGDiag.memoryTag())",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
)
|
)
|
||||||
let previousSurface = state.surface
|
|
||||||
if previousSurface == .ai, surface != .ai {
|
|
||||||
aiKeyboardCoordinator.leave()
|
|
||||||
}
|
|
||||||
state.surface = surface
|
state.surface = surface
|
||||||
if surface == .typing {
|
if surface == .typing {
|
||||||
typingSession.enterTypingMode()
|
typingSession.enterTypingMode()
|
||||||
@@ -639,9 +627,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
} else {
|
} else {
|
||||||
typingSession.leaveTypingMode()
|
typingSession.leaveTypingMode()
|
||||||
}
|
}
|
||||||
if surface == .ai {
|
|
||||||
aiKeyboardCoordinator.enterIfNeeded()
|
|
||||||
}
|
|
||||||
refreshKeyboardHeight()
|
refreshKeyboardHeight()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,9 +647,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
if resolved == .typing, let language = preference.typingLanguage {
|
if resolved == .typing, let language = preference.typingLanguage {
|
||||||
_ = typingSession.setLanguage(language)
|
_ = typingSession.setLanguage(language)
|
||||||
}
|
}
|
||||||
if resolved == .ai {
|
|
||||||
aiKeyboardCoordinator.beginNewPresentation()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When not remembering, snap to the static open preference while hidden
|
/// When not remembering, snap to the static open preference while hidden
|
||||||
@@ -733,8 +715,49 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps field-action UI current even when a host app does not immediately
|
||||||
|
/// echo this keyboard's own document mutation through `textDidChange`.
|
||||||
|
private func insertTextIntoDocument(_ text: String) {
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
textDocumentProxy.insertText(text)
|
||||||
|
// A non-empty insertion makes content actions available immediately.
|
||||||
|
// The next host callback remains the authoritative correction.
|
||||||
|
refreshAssistantFieldAction(hasTextOverride: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteBackwardFromDocument() {
|
||||||
|
textDocumentProxy.deleteBackward()
|
||||||
|
refreshAssistantFieldAction()
|
||||||
|
scheduleAssistantFieldActionRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performDocumentReturn() {
|
||||||
|
textDocumentProxy.insertText("\n")
|
||||||
|
scheduleAssistantFieldActionRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleAssistantFieldActionRefresh() {
|
||||||
|
assistantFieldActionRefreshTask?.cancel()
|
||||||
|
assistantFieldActionRefreshTask = Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
self.refreshAssistantFieldAction()
|
||||||
|
self.assistantFieldActionRefreshTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAssistantFieldAction(hasTextOverride: Bool? = nil) {
|
||||||
|
let role = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
||||||
|
state.returnKeyRole = role
|
||||||
|
state.assistantActionAvailable = role.assistantActionAvailable(
|
||||||
|
hasText: hasTextOverride ?? textDocumentProxy.hasText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshReturnKeyRole() {
|
private func refreshReturnKeyRole() {
|
||||||
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
assistantFieldActionRefreshTask?.cancel()
|
||||||
|
assistantFieldActionRefreshTask = nil
|
||||||
|
refreshAssistantFieldAction()
|
||||||
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
||||||
state.setSecureTextEntry(isSecure)
|
state.setSecureTextEntry(isSecure)
|
||||||
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
|
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
|
||||||
@@ -903,10 +926,10 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state: state,
|
state: state,
|
||||||
typing: typingSession,
|
typing: typingSession,
|
||||||
onInsert: { [weak self] text in
|
onInsert: { [weak self] text in
|
||||||
self?.textDocumentProxy.insertText(text)
|
self?.insertTextIntoDocument(text)
|
||||||
},
|
},
|
||||||
onDeleteBackward: { [weak self] in
|
onDeleteBackward: { [weak self] in
|
||||||
self?.textDocumentProxy.deleteBackward()
|
self?.deleteBackwardFromDocument()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
let host = KeyboardHostingController(rootView: root)
|
let host = KeyboardHostingController(rootView: root)
|
||||||
@@ -925,7 +948,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
])
|
])
|
||||||
host.didMove(toParent: self)
|
host.didMove(toParent: self)
|
||||||
hosting = host
|
hosting = host
|
||||||
cursorDrag?.install(on: view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - App context
|
// MARK: - App context
|
||||||
|
|||||||
@@ -14,22 +14,31 @@ final class AIKeyboardCoordinator {
|
|||||||
private let flow: KeyboardFlowCoordinator
|
private let flow: KeyboardFlowCoordinator
|
||||||
private let insertAnswer: (AIAnswer) -> Bool
|
private let insertAnswer: (AIAnswer) -> Bool
|
||||||
private let performReturn: () -> Void
|
private let performReturn: () -> Void
|
||||||
|
private let captureInsertionFingerprint: () -> String?
|
||||||
|
private var requestInsertionFingerprint: String?
|
||||||
|
private var conversationInsertionFingerprint: String?
|
||||||
|
private var hasConversationInsertionTarget = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
state: KeyboardState,
|
state: KeyboardState,
|
||||||
flow: KeyboardFlowCoordinator,
|
flow: KeyboardFlowCoordinator,
|
||||||
insertAnswer: @escaping (AIAnswer) -> Bool,
|
insertAnswer: @escaping (AIAnswer) -> Bool,
|
||||||
performReturn: @escaping () -> Void
|
performReturn: @escaping () -> Void,
|
||||||
|
captureInsertionFingerprint: @escaping () -> String?
|
||||||
) {
|
) {
|
||||||
self.state = state
|
self.state = state
|
||||||
self.flow = flow
|
self.flow = flow
|
||||||
self.insertAnswer = insertAnswer
|
self.insertAnswer = insertAnswer
|
||||||
self.performReturn = performReturn
|
self.performReturn = performReturn
|
||||||
|
self.captureInsertionFingerprint = captureInsertionFingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginNewPresentation() {
|
func beginNewPresentation() {
|
||||||
endConversationIfNeeded()
|
endConversationIfNeeded()
|
||||||
state.aiSession.enter()
|
state.aiSession.enter()
|
||||||
|
requestInsertionFingerprint = nil
|
||||||
|
conversationInsertionFingerprint = nil
|
||||||
|
hasConversationInsertionTarget = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func enterIfNeeded() {
|
func enterIfNeeded() {
|
||||||
@@ -43,6 +52,9 @@ final class AIKeyboardCoordinator {
|
|||||||
}
|
}
|
||||||
endConversationIfNeeded()
|
endConversationIfNeeded()
|
||||||
state.aiSession.leave()
|
state.aiSession.leave()
|
||||||
|
requestInsertionFingerprint = nil
|
||||||
|
conversationInsertionFingerprint = nil
|
||||||
|
hasConversationInsertionTarget = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleMicrophone() {
|
func toggleMicrophone() {
|
||||||
@@ -51,17 +63,17 @@ final class AIKeyboardCoordinator {
|
|||||||
guard let utteranceID = state.aiSession.activeUtteranceID else { return }
|
guard let utteranceID = state.aiSession.activeUtteranceID else { return }
|
||||||
state.aiSession.beginRecognizing(utteranceID: utteranceID)
|
state.aiSession.beginRecognizing(utteranceID: utteranceID)
|
||||||
flow.stopAIRecording()
|
flow.stopAIRecording()
|
||||||
case .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
|
case .idle, .awaitingSend, .inserted, .sent, .failed:
|
||||||
enterIfNeeded()
|
prepareConversationForRequest()
|
||||||
guard let conversationID = state.aiSession.conversationID else { return }
|
guard let conversationID = state.aiSession.conversationID else { return }
|
||||||
let disposition = flow.beginAIRecording(conversationID: conversationID)
|
let disposition = flow.beginAIRecording(conversationID: conversationID)
|
||||||
if case .rejected(let rejection) = disposition {
|
if case .rejected(let rejection) = disposition {
|
||||||
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
||||||
}
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
enterIfNeeded()
|
prepareConversationForRequest()
|
||||||
toggleMicrophone()
|
toggleMicrophone()
|
||||||
case .preparing, .recognizing, .generating:
|
case .preparing, .recognizing, .generating, .ready:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +81,7 @@ final class AIKeyboardCoordinator {
|
|||||||
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
|
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
|
||||||
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
||||||
guard canAcceptIdleSubmit else { return }
|
guard canAcceptIdleSubmit else { return }
|
||||||
enterIfNeeded()
|
prepareConversationForRequest()
|
||||||
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||||
if skill.kind == .export {
|
if skill.kind == .export {
|
||||||
state.pendingClipboardSkillID = skill.id
|
state.pendingClipboardSkillID = skill.id
|
||||||
@@ -77,11 +89,14 @@ final class AIKeyboardCoordinator {
|
|||||||
} else {
|
} else {
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
}
|
}
|
||||||
let instruction = AIClipboardSkillCatalog.instruction(
|
var instruction = AIClipboardSkillCatalog.instruction(
|
||||||
for: skill,
|
for: skill,
|
||||||
locale: AIHintLocaleResolver.packLocale(),
|
locale: AIHintLocaleResolver.packLocale(),
|
||||||
translationTargetLocaleId: state.translationTargetLocaleId
|
translationTargetLocaleId: state.translationTargetLocaleId
|
||||||
)
|
)
|
||||||
|
if skill.kind == .export {
|
||||||
|
instruction += "\nPreserve the source language, addresses, names, and proper nouns."
|
||||||
|
}
|
||||||
AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
|
AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
|
||||||
if let material {
|
if let material {
|
||||||
AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
|
AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
|
||||||
@@ -101,7 +116,7 @@ final class AIKeyboardCoordinator {
|
|||||||
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
||||||
func submitHintCard(_ card: AIHintCard) {
|
func submitHintCard(_ card: AIHintCard) {
|
||||||
guard canAcceptIdleSubmit else { return }
|
guard canAcceptIdleSubmit else { return }
|
||||||
enterIfNeeded()
|
prepareConversationForRequest()
|
||||||
let resolution = AIHintPool.resolvePrompt(
|
let resolution = AIHintPool.resolvePrompt(
|
||||||
for: card,
|
for: card,
|
||||||
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||||
@@ -111,10 +126,10 @@ final class AIKeyboardCoordinator {
|
|||||||
|
|
||||||
private var canAcceptIdleSubmit: Bool {
|
private var canAcceptIdleSubmit: Bool {
|
||||||
switch state.aiSession.phase {
|
switch state.aiSession.phase {
|
||||||
case .inactive, .idle, .failed:
|
case .inactive, .idle, .awaitingSend, .inserted, .sent, .failed:
|
||||||
return true
|
return true
|
||||||
case .preparing, .listening, .recognizing, .generating,
|
case .preparing, .listening, .recognizing, .generating,
|
||||||
.ready, .awaitingSend, .inserted, .sent:
|
.ready:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,23 +165,36 @@ final class AIKeyboardCoordinator {
|
|||||||
func cancel() {
|
func cancel() {
|
||||||
guard state.aiSession.isBusy else { return }
|
guard state.aiSession.isBusy else { return }
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
|
requestInsertionFingerprint = nil
|
||||||
flow.cancelAIRecording()
|
flow.cancelAIRecording()
|
||||||
state.aiSession.cancelCurrentWork()
|
state.aiSession.cancelCurrentWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendLatestAnswer() {
|
func confirmPendingAnswer() {
|
||||||
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
||||||
guard insertAnswer(answer) else { return }
|
guard insertAnswer(answer) else { return }
|
||||||
state.aiSession.markAnswerInserted(
|
state.aiSession.markAnswerInserted(
|
||||||
offersSend: state.returnKeyRole.usesActionFill
|
offersSend: state.returnKeyRole.usesActionFill
|
||||||
)
|
)
|
||||||
} else if state.aiSession.canSend {
|
conversationInsertionFingerprint = captureInsertionFingerprint()
|
||||||
|
hasConversationInsertionTarget = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardPendingAnswer() {
|
||||||
|
state.aiSession.discardReadyAnswer()
|
||||||
|
requestInsertionFingerprint = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func performCurrentFieldAction() {
|
||||||
|
guard state.assistantActionAvailable else { return }
|
||||||
|
if state.aiSession.canSend {
|
||||||
state.aiSession.markAnswerSent()
|
state.aiSession.markAnswerSent()
|
||||||
// Let the host consume the inserted answer before issuing Return.
|
}
|
||||||
Task { @MainActor [weak self] in
|
// Let the host consume the inserted answer before issuing Return.
|
||||||
await Task.yield()
|
Task { @MainActor [weak self] in
|
||||||
self?.performReturn()
|
await Task.yield()
|
||||||
}
|
self?.performReturn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,13 +258,36 @@ final class AIKeyboardCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.aiSession.receiveAnswer(answer, utteranceID: result.utteranceId)
|
state.aiSession.receiveAnswer(answer, utteranceID: result.utteranceId)
|
||||||
|
defer { requestInsertionFingerprint = nil }
|
||||||
|
guard state.aiSession.canInsert,
|
||||||
|
let expected = requestInsertionFingerprint,
|
||||||
|
captureInsertionFingerprint() == expected,
|
||||||
|
let answer = state.aiSession.answer,
|
||||||
|
insertAnswer(answer) else {
|
||||||
|
// Keep `.ready`: the unified UI presents an explicit Insert / Discard
|
||||||
|
// fallback when the field or caret changed during generation.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.aiSession.markAnswerInserted(
|
||||||
|
offersSend: state.returnKeyRole.usesActionFill
|
||||||
|
)
|
||||||
|
conversationInsertionFingerprint = captureInsertionFingerprint()
|
||||||
|
hasConversationInsertionTarget = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func fail(_ message: String, utteranceID: UUID?) {
|
func fail(_ message: String, utteranceID: UUID?) {
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
|
requestInsertionFingerprint = nil
|
||||||
state.aiSession.fail(message, utteranceID: utteranceID)
|
state.aiSession.fail(message, utteranceID: utteranceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A global output-language change starts a clean conversation so retained
|
||||||
|
/// turns cannot override the newly selected language policy.
|
||||||
|
func resetConversationForConfigurationChange() {
|
||||||
|
guard state.aiSession.isActive else { return }
|
||||||
|
beginNewPresentation()
|
||||||
|
}
|
||||||
|
|
||||||
private func endConversationIfNeeded() {
|
private func endConversationIfNeeded() {
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
guard let conversationID = state.aiSession.conversationID else { return }
|
guard let conversationID = state.aiSession.conversationID else { return }
|
||||||
@@ -248,6 +299,20 @@ final class AIKeyboardCoordinator {
|
|||||||
state.pendingClipboardSkillSource = nil
|
state.pendingClipboardSkillSource = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func prepareConversationForRequest() {
|
||||||
|
let currentFingerprint = captureInsertionFingerprint()
|
||||||
|
if state.aiSession.isActive,
|
||||||
|
hasConversationInsertionTarget,
|
||||||
|
conversationInsertionFingerprint != currentFingerprint {
|
||||||
|
beginNewPresentation()
|
||||||
|
} else {
|
||||||
|
enterIfNeeded()
|
||||||
|
}
|
||||||
|
conversationInsertionFingerprint = currentFingerprint
|
||||||
|
hasConversationInsertionTarget = true
|
||||||
|
requestInsertionFingerprint = currentFingerprint
|
||||||
|
}
|
||||||
|
|
||||||
private var isPendingExportSkill: Bool {
|
private var isPendingExportSkill: Bool {
|
||||||
guard let id = state.pendingClipboardSkillID else { return false }
|
guard let id = state.pendingClipboardSkillID else { return false }
|
||||||
return resolvedSkill(id: id)?.kind == .export
|
return resolvedSkill(id: id)?.kind == .export
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ public struct AppGroupPersistor {
|
|||||||
// the keyboard stays open.
|
// the keyboard stays open.
|
||||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||||
state.handednessPreference = store.handednessPreference
|
state.handednessPreference = store.handednessPreference
|
||||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
|
||||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||||
@@ -96,7 +95,6 @@ public struct AppGroupPersistor {
|
|||||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||||
}
|
}
|
||||||
state.handednessPreference = store.handednessPreference
|
state.handednessPreference = store.handednessPreference
|
||||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
|
||||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
// CursorDragController.swift
|
|
||||||
// OSGKeyboard · Keyboard Extension
|
|
||||||
//
|
|
||||||
// Cursor-drag hint chrome and batched caret moves via textDocumentProxy.
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class CursorDragController {
|
|
||||||
private let state: KeyboardState
|
|
||||||
private let adjustTextPosition: (Int) -> Void
|
|
||||||
private weak var parentView: UIView?
|
|
||||||
|
|
||||||
private var cursorDragHintLabel: UILabel?
|
|
||||||
private var pendingHorizontalCursorSteps = 0
|
|
||||||
private var pendingVerticalCursorSteps = 0
|
|
||||||
private var cursorMoveFlushScheduled = false
|
|
||||||
private let cursorLineHaptic = UIImpactFeedbackGenerator(style: .light)
|
|
||||||
|
|
||||||
private static let cursorVerticalChunkSize = 20
|
|
||||||
|
|
||||||
init(
|
|
||||||
state: KeyboardState,
|
|
||||||
adjustTextPosition: @escaping (Int) -> Void
|
|
||||||
) {
|
|
||||||
self.state = state
|
|
||||||
self.adjustTextPosition = adjustTextPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
func install(on view: UIView) {
|
|
||||||
parentView = view
|
|
||||||
let hint = UILabel()
|
|
||||||
hint.text = ExtL10n.string("keyboard.cursorDrag.centerHint")
|
|
||||||
hint.font = .systemFont(ofSize: 22, weight: .medium)
|
|
||||||
hint.textColor = UIColor.label.withAlphaComponent(0.10)
|
|
||||||
hint.textAlignment = .center
|
|
||||||
hint.numberOfLines = 1
|
|
||||||
hint.adjustsFontSizeToFitWidth = true
|
|
||||||
hint.minimumScaleFactor = 0.7
|
|
||||||
hint.isUserInteractionEnabled = false
|
|
||||||
hint.isHidden = true
|
|
||||||
hint.alpha = 0
|
|
||||||
view.addSubview(hint)
|
|
||||||
cursorDragHintLabel = hint
|
|
||||||
layoutChrome()
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutChrome() {
|
|
||||||
guard let view = parentView else { return }
|
|
||||||
cursorDragHintLabel?.frame = view.bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCursorDragActive(_ active: Bool) {
|
|
||||||
state.cursorDragActive = active
|
|
||||||
updateCursorDragWash(active: active)
|
|
||||||
}
|
|
||||||
|
|
||||||
func moveCursorHorizontally(by steps: Int) {
|
|
||||||
guard steps != 0 else { return }
|
|
||||||
pendingHorizontalCursorSteps += steps
|
|
||||||
scheduleCursorMoveFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
func moveCursorVertically(by steps: Int) {
|
|
||||||
guard steps != 0 else { return }
|
|
||||||
pendingVerticalCursorSteps += steps
|
|
||||||
scheduleCursorMoveFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateCursorDragWash(active: Bool) {
|
|
||||||
if active {
|
|
||||||
cursorLineHaptic.prepare()
|
|
||||||
}
|
|
||||||
layoutChrome()
|
|
||||||
guard let hint = cursorDragHintLabel else { return }
|
|
||||||
if active {
|
|
||||||
hint.isHidden = false
|
|
||||||
UIView.animate(withDuration: 0.12) { hint.alpha = 1 }
|
|
||||||
} else {
|
|
||||||
UIView.animate(withDuration: 0.12, animations: { hint.alpha = 0 }) { [weak self] _ in
|
|
||||||
guard let self, !self.state.cursorDragActive else { return }
|
|
||||||
hint.isHidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleCursorMoveFlush() {
|
|
||||||
guard !cursorMoveFlushScheduled else { return }
|
|
||||||
cursorMoveFlushScheduled = true
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.012) { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.cursorMoveFlushScheduled = false
|
|
||||||
|
|
||||||
let horizontal = self.pendingHorizontalCursorSteps
|
|
||||||
let vertical = self.pendingVerticalCursorSteps
|
|
||||||
self.pendingHorizontalCursorSteps = 0
|
|
||||||
self.pendingVerticalCursorSteps = 0
|
|
||||||
|
|
||||||
if horizontal != 0 {
|
|
||||||
OSGLog.keyboardExt.info("adjustTextPosition h=\(horizontal)")
|
|
||||||
self.adjustTextPosition(horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
if vertical != 0 {
|
|
||||||
self.applyVerticalCursorSteps(vertical)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.pendingHorizontalCursorSteps != 0 || self.pendingVerticalCursorSteps != 0 {
|
|
||||||
self.scheduleCursorMoveFlush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyVerticalCursorSteps(_ steps: Int) {
|
|
||||||
let direction = steps > 0 ? 1 : -1
|
|
||||||
var remaining = abs(steps)
|
|
||||||
let chunk = Self.cursorVerticalChunkSize
|
|
||||||
|
|
||||||
while remaining > 0 {
|
|
||||||
adjustTextPosition(direction * chunk)
|
|
||||||
cursorLineHaptic.impactOccurred()
|
|
||||||
cursorLineHaptic.prepare()
|
|
||||||
remaining -= 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,9 +64,7 @@ final class KeyboardConfigSync {
|
|||||||
func loadPersistedConfig() -> AppGroupLoadResult {
|
func loadPersistedConfig() -> AppGroupLoadResult {
|
||||||
switch persistor.load(into: state) {
|
switch persistor.load(into: state) {
|
||||||
case .loaded:
|
case .loaded:
|
||||||
OSGLog.keyboardExt.info(
|
OSGLog.keyboardExt.info("config loaded")
|
||||||
"config loaded — cursorDragNavigationEnabled=\(self.state.cursorDragNavigationEnabled)"
|
|
||||||
)
|
|
||||||
syncOnboardingStateFromAppGroup()
|
syncOnboardingStateFromAppGroup()
|
||||||
return .loaded
|
return .loaded
|
||||||
case .unavailable:
|
case .unavailable:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ final class KeyboardTextInserter {
|
|||||||
/// Suppresses availability refresh while we walk `deleteBackward`
|
/// Suppresses availability refresh while we walk `deleteBackward`
|
||||||
/// for undo, so intermediate contexts don't flicker the button.
|
/// for undo, so intermediate contexts don't flicker the button.
|
||||||
private var isUndoing = false
|
private var isUndoing = false
|
||||||
|
private var successPulseTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
state: KeyboardState,
|
state: KeyboardState,
|
||||||
@@ -155,6 +156,7 @@ final class KeyboardTextInserter {
|
|||||||
// The paste pushed any previous input away from the caret, so the
|
// The paste pushed any previous input away from the caret, so the
|
||||||
// "editable last input" hint no longer applies.
|
// "editable last input" hint no longer applies.
|
||||||
clearEditHintIfPositive()
|
clearEditHintIfPositive()
|
||||||
|
state.editAvailable = false
|
||||||
OSGLog.keyboardExt.info("clipboard insert length=\(text.count, privacy: .public)")
|
OSGLog.keyboardExt.info("clipboard insert length=\(text.count, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +186,8 @@ final class KeyboardTextInserter {
|
|||||||
redoContextBefore = contextBeforeInput()
|
redoContextBefore = contextBeforeInput()
|
||||||
lastInsertedText = nil
|
lastInsertedText = nil
|
||||||
state.undoAvailable = false
|
state.undoAvailable = false
|
||||||
|
state.editAvailable = false
|
||||||
|
EditableInputReferenceStore.clear()
|
||||||
OSGLog.keyboardExt.info("undo length=\(text.count, privacy: .public)")
|
OSGLog.keyboardExt.info("undo length=\(text.count, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +266,10 @@ final class KeyboardTextInserter {
|
|||||||
if state.cutAvailable != hasSelection {
|
if state.cutAvailable != hasSelection {
|
||||||
state.cutAvailable = hasSelection
|
state.cutAvailable = hasSelection
|
||||||
}
|
}
|
||||||
|
let editAvailable = editableReference() != nil
|
||||||
|
if state.editAvailable != editAvailable {
|
||||||
|
state.editAvailable = editAvailable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func editableReference() -> EditableInputReference? {
|
func editableReference() -> EditableInputReference? {
|
||||||
@@ -288,15 +296,25 @@ final class KeyboardTextInserter {
|
|||||||
preceding.hasSuffix(reference.insertedText) else {
|
preceding.hasSuffix(reference.insertedText) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// The current extension instance owns an exact in-memory insertion
|
||||||
|
// record. Prefer it before the cross-process field fingerprint:
|
||||||
|
// UITextDocumentProxy can publish its updated surrounding context one
|
||||||
|
// callback after `insertText`, which otherwise makes a freshly shown
|
||||||
|
// Edit button disappear or reject its first tap.
|
||||||
|
if reference.matchesLiveInsertion(
|
||||||
|
extensionInstanceID: extensionInstanceID,
|
||||||
|
lastInsertedText: lastInsertedText,
|
||||||
|
contextBeforeInput: preceding
|
||||||
|
) {
|
||||||
|
return reference
|
||||||
|
}
|
||||||
guard reference.postInsertionFingerprint == nil
|
guard reference.postInsertionFingerprint == nil
|
||||||
|| reference.postInsertionFingerprint
|
|| reference.postInsertionFingerprint
|
||||||
== fieldContextProvider()?.deliveryFingerprint else {
|
== fieldContextProvider()?.deliveryFingerprint else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if reference.extensionInstanceID == extensionInstanceID,
|
// Rebuilt extension instances have no trusted in-memory insertion
|
||||||
lastInsertedText == reference.insertedText {
|
// record, so they continue to require the complete field fingerprint.
|
||||||
return reference
|
|
||||||
}
|
|
||||||
return reference.isFullyVerified(
|
return reference.isFullyVerified(
|
||||||
contextBeforeInput: preceding,
|
contextBeforeInput: preceding,
|
||||||
fieldFingerprint: fieldContextProvider()?.deliveryFingerprint
|
fieldFingerprint: fieldContextProvider()?.deliveryFingerprint
|
||||||
@@ -512,6 +530,14 @@ final class KeyboardTextInserter {
|
|||||||
extensionInstanceID: extensionInstanceID
|
extensionInstanceID: extensionInstanceID
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
state.editAvailable = true
|
||||||
|
state.assistantInsertionSucceeded = true
|
||||||
|
successPulseTask?.cancel()
|
||||||
|
successPulseTask = Task { @MainActor [weak state] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(450))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
state?.assistantInsertionSucceeded = false
|
||||||
|
}
|
||||||
let hint = ExtL10n.string("keyboard.edit.hint.available")
|
let hint = ExtL10n.string("keyboard.edit.hint.available")
|
||||||
editHintScheduler.show(
|
editHintScheduler.show(
|
||||||
message: hint,
|
message: hint,
|
||||||
@@ -527,6 +553,7 @@ final class KeyboardTextInserter {
|
|||||||
private func clearLastInsertion() {
|
private func clearLastInsertion() {
|
||||||
lastInsertedText = nil
|
lastInsertedText = nil
|
||||||
state.undoAvailable = false
|
state.undoAvailable = false
|
||||||
|
state.editAvailable = false
|
||||||
EditableInputReferenceStore.clear()
|
EditableInputReferenceStore.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct KeyboardSurfaceRoot: View {
|
|||||||
Group {
|
Group {
|
||||||
switch state.surface {
|
switch state.surface {
|
||||||
case .voice:
|
case .voice:
|
||||||
KeyboardRootView(
|
AIKeyboardView(
|
||||||
state: state,
|
state: state,
|
||||||
typing: typing,
|
typing: typing,
|
||||||
onInsert: wrappedInsert
|
onInsert: wrappedInsert
|
||||||
@@ -50,6 +50,8 @@ struct KeyboardSurfaceRoot: View {
|
|||||||
onDeleteBackward: wrappedDeleteBackward
|
onDeleteBackward: wrappedDeleteBackward
|
||||||
)
|
)
|
||||||
case .ai:
|
case .ai:
|
||||||
|
// Legacy persisted surface; controller canonicalizes it to
|
||||||
|
// `.voice`, but render the unified assistant defensively.
|
||||||
AIKeyboardView(
|
AIKeyboardView(
|
||||||
state: state,
|
state: state,
|
||||||
typing: typing,
|
typing: typing,
|
||||||
|
|||||||
@@ -174,8 +174,7 @@ final class TypingKeyTouchPadUIView: UIView {
|
|||||||
bottomRowMinY: 0
|
bottomRowMinY: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
/// Same trick as CursorDragPad: non-zero alpha so SwiftUI hosting does
|
/// Non-zero alpha keeps SwiftUI hosting from treating the pad as pass-through.
|
||||||
/// not treat the pad as pass-through.
|
|
||||||
private static let padTint = UIColor { traits in
|
private static let padTint = UIColor { traits in
|
||||||
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
|
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -324,7 +324,7 @@ private struct ClipboardHistoryRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Top clipboard button (replaces translation chip slot)
|
// MARK: - Top clipboard button (right of the translation chip)
|
||||||
|
|
||||||
struct KeyboardClipboardMenuButton: View, Equatable {
|
struct KeyboardClipboardMenuButton: View, Equatable {
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@@ -359,6 +359,7 @@ struct KeyboardClipboardMenuButton: View, Equatable {
|
|||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("assistant.clipboard")
|
||||||
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.a11y"))
|
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.a11y"))
|
||||||
.accessibilityHint(ExtL10n.text("keyboard.clipboard.a11yHint"))
|
.accessibilityHint(ExtL10n.text("keyboard.clipboard.a11yHint"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
// CursorDragPad.swift
|
|
||||||
// OSGKeyboard · Keyboard Extension
|
|
||||||
//
|
|
||||||
// SwiftUI layout wrapper for a UIKit pan recognizer. SwiftUI gestures
|
|
||||||
// can be unreliable in keyboard-extension hosting views; keeping the
|
|
||||||
// recognizer in UIKit preserves the existing layout while avoiding that
|
|
||||||
// failure mode.
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import UIKit
|
|
||||||
import os
|
|
||||||
|
|
||||||
private let cursorDragLog = Logger(subsystem: "com.osgkeyboard.ios", category: "CursorDrag")
|
|
||||||
|
|
||||||
struct CursorDragPad: UIViewRepresentable {
|
|
||||||
let enabled: Bool
|
|
||||||
let onPressingChanged: (Bool) -> Void
|
|
||||||
let moveHorizontal: (Int) -> Void
|
|
||||||
let moveVertical: (Int) -> Void
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> CursorDragPadUIView {
|
|
||||||
cursorDragLog.info("makeUIView (enabled=\(enabled))")
|
|
||||||
let view = CursorDragPadUIView()
|
|
||||||
view.coordinator = context.coordinator
|
|
||||||
view.isPadEnabled = enabled
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: CursorDragPadUIView, context: Context) {
|
|
||||||
context.coordinator.onPressingChanged = onPressingChanged
|
|
||||||
context.coordinator.moveHorizontal = moveHorizontal
|
|
||||||
context.coordinator.moveVertical = moveVertical
|
|
||||||
uiView.isPadEnabled = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator(
|
|
||||||
onPressingChanged: onPressingChanged,
|
|
||||||
moveHorizontal: moveHorizontal,
|
|
||||||
moveVertical: moveVertical
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
final class Coordinator {
|
|
||||||
var onPressingChanged: (Bool) -> Void
|
|
||||||
var moveHorizontal: (Int) -> Void
|
|
||||||
var moveVertical: (Int) -> Void
|
|
||||||
|
|
||||||
init(
|
|
||||||
onPressingChanged: @escaping (Bool) -> Void,
|
|
||||||
moveHorizontal: @escaping (Int) -> Void,
|
|
||||||
moveVertical: @escaping (Int) -> Void
|
|
||||||
) {
|
|
||||||
self.onPressingChanged = onPressingChanged
|
|
||||||
self.moveHorizontal = moveHorizontal
|
|
||||||
self.moveVertical = moveVertical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class CursorDragPadUIView: UIView, UIGestureRecognizerDelegate {
|
|
||||||
weak var coordinator: CursorDragPad.Coordinator?
|
|
||||||
|
|
||||||
var isPadEnabled = true {
|
|
||||||
didSet {
|
|
||||||
isUserInteractionEnabled = isPadEnabled
|
|
||||||
applyIdleTint()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Pad tint
|
|
||||||
// MUST stay non-zero. When embedded via `UIViewRepresentable`, a fully
|
|
||||||
// transparent (alpha 0) background makes SwiftUI's host treat the region
|
|
||||||
// as empty pass-through space and the pad stops receiving touches. A tiny
|
|
||||||
// alpha (just above UIKit's 0.01 hit-test threshold) keeps the pad fully
|
|
||||||
// draggable while remaining imperceptible.
|
|
||||||
//
|
|
||||||
// The keyboard surface itself is transparent (system chrome shows
|
|
||||||
// through), so there is no fixed colour to match; `systemGray4` tracks
|
|
||||||
// the system keyboard's grey in both light and dark and, at ~2% alpha,
|
|
||||||
// blends invisibly. `withAlphaComponent` on a dynamic colour can freeze
|
|
||||||
// the current trait, so resolve per-trait to stay appearance-adaptive.
|
|
||||||
private static let padTint = UIColor { traits in
|
|
||||||
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
|
|
||||||
}
|
|
||||||
private static var idleTint: UIColor { padTint }
|
|
||||||
private static var activeTint: UIColor { padTint }
|
|
||||||
|
|
||||||
private func applyIdleTint() {
|
|
||||||
backgroundColor = isPadEnabled ? Self.idleTint : .clear
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastTranslation = CGPoint.zero
|
|
||||||
private var horizontalCarry: CGFloat = 0
|
|
||||||
private var verticalCarry: CGFloat = 0
|
|
||||||
private var didFireBeginHaptic = false
|
|
||||||
/// Once the finger clears the dead zone, lock to one axis so slight
|
|
||||||
/// diagonal jitter does not flip between horizontal and vertical steps.
|
|
||||||
private var lockedAxis: LockedAxis?
|
|
||||||
|
|
||||||
private enum LockedAxis {
|
|
||||||
case horizontal
|
|
||||||
case vertical
|
|
||||||
}
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
super.init(frame: frame)
|
|
||||||
backgroundColor = Self.idleTint
|
|
||||||
isMultipleTouchEnabled = false
|
|
||||||
isUserInteractionEnabled = true
|
|
||||||
|
|
||||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
|
||||||
pan.delegate = self
|
|
||||||
pan.minimumNumberOfTouches = 1
|
|
||||||
pan.maximumNumberOfTouches = 1
|
|
||||||
pan.cancelsTouchesInView = false
|
|
||||||
pan.delaysTouchesBegan = false
|
|
||||||
pan.delaysTouchesEnded = false
|
|
||||||
addGestureRecognizer(pan)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didMoveToWindow() {
|
|
||||||
super.didMoveToWindow()
|
|
||||||
let size = "\(Int(bounds.width))x\(Int(bounds.height))"
|
|
||||||
cursorDragLog.info("didMoveToWindow size=\(size, privacy: .public) attached=\(self.window != nil)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
||||||
let hit = super.hitTest(point, with: event)
|
|
||||||
if hit === self {
|
|
||||||
cursorDragLog.debug("hitTest inside pad")
|
|
||||||
}
|
|
||||||
return hit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raw touch delivery drives the "drag mode" state so a static hold
|
|
||||||
// (which a pan recognizer ignores until the finger moves) already
|
|
||||||
// switches the keyboard into cursor-drag chrome.
|
|
||||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
||||||
super.touchesBegan(touches, with: event)
|
|
||||||
guard isPadEnabled else { return }
|
|
||||||
backgroundColor = Self.activeTint
|
|
||||||
coordinator?.onPressingChanged(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
||||||
super.touchesEnded(touches, with: event)
|
|
||||||
applyIdleTint()
|
|
||||||
coordinator?.onPressingChanged(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
||||||
super.touchesCancelled(touches, with: event)
|
|
||||||
applyIdleTint()
|
|
||||||
coordinator?.onPressingChanged(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
|
||||||
guard isPadEnabled, let coordinator else { return }
|
|
||||||
|
|
||||||
switch gesture.state {
|
|
||||||
case .began:
|
|
||||||
resetGestureState()
|
|
||||||
backgroundColor = Self.activeTint
|
|
||||||
coordinator.onPressingChanged(true)
|
|
||||||
cursorDragLog.info("pan began")
|
|
||||||
case .changed:
|
|
||||||
handlePanChanged(gesture, coordinator: coordinator)
|
|
||||||
case .ended, .cancelled, .failed:
|
|
||||||
resetGestureState()
|
|
||||||
applyIdleTint()
|
|
||||||
coordinator.onPressingChanged(false)
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePanChanged(
|
|
||||||
_ gesture: UIPanGestureRecognizer,
|
|
||||||
coordinator: CursorDragPad.Coordinator
|
|
||||||
) {
|
|
||||||
let translation = gesture.translation(in: self)
|
|
||||||
let delta = CGPoint(
|
|
||||||
x: translation.x - lastTranslation.x,
|
|
||||||
y: translation.y - lastTranslation.y
|
|
||||||
)
|
|
||||||
lastTranslation = translation
|
|
||||||
|
|
||||||
let deadZone: CGFloat = 6
|
|
||||||
guard max(abs(translation.x), abs(translation.y)) > deadZone else { return }
|
|
||||||
|
|
||||||
if !didFireBeginHaptic {
|
|
||||||
didFireBeginHaptic = true
|
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
||||||
}
|
|
||||||
|
|
||||||
if lockedAxis == nil {
|
|
||||||
lockedAxis = abs(translation.x) >= abs(translation.y) ? .horizontal : .vertical
|
|
||||||
}
|
|
||||||
|
|
||||||
switch lockedAxis {
|
|
||||||
case .horizontal:
|
|
||||||
horizontalCarry += delta.x
|
|
||||||
let threshold = stepThreshold(for: translation.x)
|
|
||||||
let steps = consumeCarry(&horizontalCarry, threshold: threshold)
|
|
||||||
if steps != 0 {
|
|
||||||
coordinator.moveHorizontal(steps)
|
|
||||||
}
|
|
||||||
case .vertical:
|
|
||||||
verticalCarry += delta.y
|
|
||||||
let threshold = stepThreshold(for: translation.y) * Self.verticalSensitivityDamping
|
|
||||||
let steps = consumeCarry(&verticalCarry, threshold: threshold)
|
|
||||||
if steps != 0 {
|
|
||||||
coordinator.moveVertical(steps)
|
|
||||||
}
|
|
||||||
case .none:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetGestureState() {
|
|
||||||
lastTranslation = .zero
|
|
||||||
horizontalCarry = 0
|
|
||||||
verticalCarry = 0
|
|
||||||
didFireBeginHaptic = false
|
|
||||||
lockedAxis = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vertical steps move in large character chunks, so require more finger
|
|
||||||
/// travel per step than horizontal to keep them from firing too fast.
|
|
||||||
/// Higher = less sensitive.
|
|
||||||
private static let verticalSensitivityDamping: CGFloat = 2.6
|
|
||||||
|
|
||||||
/// Farther drag means a smaller threshold and faster stepping,
|
|
||||||
/// capped so long swipes remain controllable.
|
|
||||||
private func stepThreshold(for totalAxisDistance: CGFloat) -> CGFloat {
|
|
||||||
let deadZone: CGFloat = 6
|
|
||||||
let accelerated = max(0, abs(totalAxisDistance) - deadZone)
|
|
||||||
let progress = min(1, accelerated / 100)
|
|
||||||
return 12 - progress * 7
|
|
||||||
}
|
|
||||||
|
|
||||||
private func consumeCarry(_ carry: inout CGFloat, threshold: CGFloat) -> Int {
|
|
||||||
guard threshold > 0 else { return 0 }
|
|
||||||
let steps = Int(carry / threshold)
|
|
||||||
if steps != 0 {
|
|
||||||
carry -= CGFloat(steps) * threshold
|
|
||||||
}
|
|
||||||
return steps
|
|
||||||
}
|
|
||||||
|
|
||||||
func gestureRecognizer(
|
|
||||||
_ gestureRecognizer: UIGestureRecognizer,
|
|
||||||
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
|
||||||
) -> Bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,709 +0,0 @@
|
|||||||
// KeyboardRootView.swift
|
|
||||||
// OSGKeyboard · Keyboard Extension
|
|
||||||
//
|
|
||||||
// Typeless-inspired keyboard surface. The keyboard is laid out in three
|
|
||||||
// vertical bands, but the entire height is reserved for us — we set
|
|
||||||
// `KeyboardViewController` drives height on `view` (priority 999) and mirrors
|
|
||||||
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI — the input view is bottom-
|
|
||||||
// anchored so a transient over-tall system container cannot float the chrome.
|
|
||||||
//
|
|
||||||
// ┌───────────────────────────────────────────┐
|
|
||||||
// │ [OSG] 语音 中文 EN 译 │ ← header band (top)
|
|
||||||
// │ (transcript preview) │
|
|
||||||
// │ ┊ │
|
|
||||||
// │ ⟲ ◯ mic (centred) │ ← action cluster:
|
|
||||||
// │ [delete] [ return ] [space] │ mic + undo + bottom row
|
|
||||||
// │ ┊ │
|
|
||||||
// └───────────────────────────────────────────┘
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
private enum KeyboardLayoutMetrics {
|
|
||||||
static let micSize: CGFloat = 121
|
|
||||||
static let micToButtonGap: CGFloat = 8
|
|
||||||
/// Circular undo / translation keys beside the mic (outer edge).
|
|
||||||
static let undoButtonSize: CGFloat = 52
|
|
||||||
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
|
|
||||||
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
|
|
||||||
/// Gap between the top control row and the transcript / hint line.
|
|
||||||
/// Four points keeps the "点按说话" line visually attached to the controls.
|
|
||||||
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
|
|
||||||
/// Match the typing key grid's outer edge.
|
|
||||||
static let sideActionHorizontalInset: CGFloat = KeyboardChromeLayout.horizontalInset
|
|
||||||
/// iPad: cap the content column. A full-width (~1180 pt) keyboard would
|
|
||||||
/// 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.
|
|
||||||
/// 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
|
|
||||||
static let outerPaddingBottom: CGFloat = 4
|
|
||||||
static let topBarHeight: CGFloat = KeyboardTopBarMetrics.height
|
|
||||||
static let transcriptLineHeight: CGFloat = 22
|
|
||||||
/// mic (121) + gap (8) + bottom row (50) = 179 pt
|
|
||||||
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
|
|
||||||
/// Moves the action cluster down so its keys share the typing row's baseline.
|
|
||||||
static let actionClusterTopGap: CGFloat = Spacing.xl
|
|
||||||
/// Centres the mic between the transcript hint and bottom action row.
|
|
||||||
static let micUpwardAdjustment: CGFloat = (actionClusterTopGap - micToButtonGap) / 2
|
|
||||||
/// The shared 4 pt outer padding is the complete bottom inset.
|
|
||||||
static let actionClusterBottomGap: CGFloat = 0
|
|
||||||
|
|
||||||
/// `RecordButton` draws its outer ring at 106 pt inside the 121 pt touch
|
|
||||||
/// frame, so the disc's *visible* top edge sits below the frame's top.
|
|
||||||
static let micRingInset: CGFloat = (micSize - 106) / 2
|
|
||||||
/// 语音/中文/EN capsule: 30 pt buttons + 2 pt padding, centred in the top bar.
|
|
||||||
static let topBarTabCapsuleHeight: CGFloat = 34
|
|
||||||
/// 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. `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
|
|
||||||
return (capsuleBottom + micVisibleTop) / 2 - currentCentre
|
|
||||||
}
|
|
||||||
|
|
||||||
static var headerBandHeight: CGFloat {
|
|
||||||
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
|
||||||
|
|
||||||
@ObservedObject var state: State
|
|
||||||
@ObservedObject var typing: TypingSessionController
|
|
||||||
let onInsert: (String) -> Void
|
|
||||||
|
|
||||||
public init(
|
|
||||||
state: KeyboardViewController.State,
|
|
||||||
typing: TypingSessionController,
|
|
||||||
onInsert: @escaping (String) -> Void = { _ in }
|
|
||||||
) {
|
|
||||||
self.state = state
|
|
||||||
self.typing = typing
|
|
||||||
self.onInsert = onInsert
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Content-driven keyboard height; mirrored on `UIInputViewController.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. iPad adds
|
|
||||||
/// `KeyboardLayoutMetrics.extraVerticalSpace` on top of this.
|
|
||||||
static let micTopOffset: CGFloat = KeyboardLayoutMetrics.outerPaddingTop
|
|
||||||
+ KeyboardLayoutMetrics.headerBandHeight
|
|
||||||
+ KeyboardLayoutMetrics.actionClusterTopGap
|
|
||||||
- KeyboardLayoutMetrics.micUpwardAdjustment
|
|
||||||
/// Horizontal inset the side pads should respect.
|
|
||||||
static let sideInset: CGFloat = KeyboardLayoutMetrics.sideActionHorizontalInset
|
|
||||||
|
|
||||||
private var palette: ThemePalette {
|
|
||||||
colorScheme == .dark ? Palette.dark : Palette.light
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
Group {
|
|
||||||
if state.editSession.isActive {
|
|
||||||
LastInputEditView(state: state)
|
|
||||||
} else {
|
|
||||||
ZStack {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
headerBand
|
|
||||||
|
|
||||||
Color.clear
|
|
||||||
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
|
|
||||||
.animation(.easeInOut(duration: 0.12), value: state.editSession.isActive)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Top brand / mode row + transcript / hint line.
|
|
||||||
private var headerBand: some View {
|
|
||||||
VStack(spacing: KeyboardLayoutMetrics.topBarToTranscriptSpacing) {
|
|
||||||
topBar
|
|
||||||
.frame(height: KeyboardLayoutMetrics.topBarHeight)
|
|
||||||
|
|
||||||
TranscriptLine(
|
|
||||||
phase: state.phase,
|
|
||||||
transcript: state.lastTranscript,
|
|
||||||
micVoiceAvailability: state.micVoiceAvailability,
|
|
||||||
micDisabledHint: state.micDisabledHint,
|
|
||||||
editHint: state.editHint,
|
|
||||||
editHintIsPositive: state.editHintIsPositive,
|
|
||||||
cursorDragHintActive: state.cursorDragActive,
|
|
||||||
openSettings: state.openSettings
|
|
||||||
)
|
|
||||||
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
|
|
||||||
.offset(y: KeyboardLayoutMetrics.transcriptLineDownwardAdjustment(
|
|
||||||
extraSpace: KeyboardLayoutMetrics.extraVerticalSpace(
|
|
||||||
isIPad: state.usesIPadLayoutMetrics,
|
|
||||||
width: state.layoutWidth
|
|
||||||
)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Top bar
|
|
||||||
|
|
||||||
private var topBar: some View {
|
|
||||||
Group {
|
|
||||||
if state.canCancelVoiceInput {
|
|
||||||
HStack(spacing: Spacing.xs) {
|
|
||||||
KeyboardBrandLogo(action: state.openSettings)
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
KeyboardCancelButton(
|
|
||||||
action: state.cancelVoiceInput,
|
|
||||||
accessibilityLabel: ExtL10n.text("keyboard.voice.cancel"),
|
|
||||||
accessibilityHint: ExtL10n.text("keyboard.voice.cancelHint")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if shouldShowClipboardSuggestion {
|
|
||||||
// Occupies the logo + capsule-tab slot until dismissed.
|
|
||||||
ClipboardSuggestionBar(
|
|
||||||
text: state.clipboardSuggestionText ?? "",
|
|
||||||
onInsert: {
|
|
||||||
if let text = state.clipboardSuggestionText {
|
|
||||||
state.insertClipboardText(text)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismiss: state.dismissClipboardSuggestion
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ZStack {
|
|
||||||
KeyboardTopControls(
|
|
||||||
state: state,
|
|
||||||
typing: typing,
|
|
||||||
palette: palette,
|
|
||||||
onInsert: onInsert
|
|
||||||
)
|
|
||||||
HStack {
|
|
||||||
KeyboardBrandLogo(action: state.openSettings)
|
|
||||||
// Globe key now lives at the bottom-left of the keyboard (matching
|
|
||||||
// iOS system layout); see micActionRow's bottom HStack.
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Action cluster
|
|
||||||
|
|
||||||
/// Mic centred above a bottom row: delete · smart return · space (or swapped).
|
|
||||||
/// The side cursor-drag pads are SwiftUI layout wrappers around UIKit
|
|
||||||
/// pan recognizers, avoiding SwiftUI gesture delivery issues in
|
|
||||||
/// keyboard extensions. A square undo key sits on the outer pad,
|
|
||||||
/// vertically centred with the mic and mirrored with handedness.
|
|
||||||
private var micActionRow: some View {
|
|
||||||
let editingBlocked = voiceInputBlocksEditing
|
|
||||||
let swapKeys = state.handednessPreference.swapsActionKeys
|
|
||||||
let cursorPadsEnabled = state.cursorDragNavigationEnabled && !editingBlocked
|
|
||||||
|
|
||||||
// Dragging hides the mic + bottom keys (kept in the layout via
|
|
||||||
// opacity so the pads' hit area never shifts mid-gesture) and lets
|
|
||||||
// the cursor-drag chrome take over.
|
|
||||||
let dragging = state.cursorDragActive
|
|
||||||
let recording = state.phase == .recording
|
|
||||||
let undoVisible = !dragging && !recording
|
|
||||||
|
|
||||||
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
cursorDragPad(enabled: cursorPadsEnabled)
|
|
||||||
.overlay(alignment: .leading) {
|
|
||||||
// Left-handed: undo shares the outer edge with delete.
|
|
||||||
if !swapKeys {
|
|
||||||
undoButton(
|
|
||||||
disabled: editingBlocked || !state.undoAvailable,
|
|
||||||
visible: undoVisible
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Right-handed undo is on the trailing pad; put
|
|
||||||
// translation on the leading (mic-left) side.
|
|
||||||
translationButton(visible: undoVisible)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RecordButton(
|
|
||||||
phase: buttonPhase,
|
|
||||||
level: state.level,
|
|
||||||
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
|
||||||
isEnabled: micButtonEnabled,
|
|
||||||
usesLiquidGlass: true,
|
|
||||||
onToggle: state.tapMic,
|
|
||||||
onPressingChanged: micButtonEnabled
|
|
||||||
? state.setMicTouchActive
|
|
||||||
: { _ in },
|
|
||||||
onEditLongPressBegan: micButtonEnabled
|
|
||||||
? state.beginEditLastInput
|
|
||||||
: nil
|
|
||||||
)
|
|
||||||
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
|
|
||||||
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
|
|
||||||
.opacity(dragging ? 0 : 1)
|
|
||||||
|
|
||||||
cursorDragPad(enabled: cursorPadsEnabled)
|
|
||||||
.overlay(alignment: .trailing) {
|
|
||||||
// Right-handed: undo mirrors to the outer (delete) side.
|
|
||||||
if swapKeys {
|
|
||||||
undoButton(
|
|
||||||
disabled: editingBlocked || !state.undoAvailable,
|
|
||||||
visible: undoVisible
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Default: translation sits on the mic's right,
|
|
||||||
// symmetric with undo on the left.
|
|
||||||
translationButton(visible: undoVisible)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: KeyboardLayoutMetrics.micSize)
|
|
||||||
|
|
||||||
GeometryReader { proxy in
|
|
||||||
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) {
|
|
||||||
// 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 || recording ? 0 : 1)
|
|
||||||
.allowsHitTesting(!dragging && !recording)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cursorDragPad(enabled: Bool) -> some View {
|
|
||||||
CursorDragPad(
|
|
||||||
enabled: enabled,
|
|
||||||
onPressingChanged: state.setCursorDragActive,
|
|
||||||
moveHorizontal: state.moveCursorHorizontal,
|
|
||||||
moveVertical: state.moveCursorVertical
|
|
||||||
)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
|
|
||||||
private func bottomDeleteButton(disabled: Bool) -> some View {
|
|
||||||
RepeatingDeleteButton(
|
|
||||||
disabled: disabled,
|
|
||||||
hapticIntensity: state.keyboardHapticIntensity
|
|
||||||
) {
|
|
||||||
state.deleteBackward()
|
|
||||||
}
|
|
||||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Circular undo key on the outer drag pad — same chrome / haptic / click
|
|
||||||
/// as space & return. Vertically matches the mic disc.
|
|
||||||
private func undoButton(disabled: Bool, visible: Bool) -> some View {
|
|
||||||
RectangularToolbarButton(
|
|
||||||
systemName: "arrow.uturn.backward",
|
|
||||||
label: ExtL10n.string("keyboard.undoA11y"),
|
|
||||||
disabled: disabled,
|
|
||||||
usesLiquidGlass: true,
|
|
||||||
usesCircleGlass: true,
|
|
||||||
hapticIntensity: state.keyboardHapticIntensity
|
|
||||||
) {
|
|
||||||
state.undoLastInsertion()
|
|
||||||
}
|
|
||||||
.frame(
|
|
||||||
width: KeyboardLayoutMetrics.undoButtonSize,
|
|
||||||
height: KeyboardLayoutMetrics.undoButtonSize
|
|
||||||
)
|
|
||||||
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
|
|
||||||
.opacity(visible ? 1 : 0)
|
|
||||||
.allowsHitTesting(visible && !disabled)
|
|
||||||
.accessibilityHidden(!visible)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Translation chip relocated from the top bar — mirrors undo across the mic.
|
|
||||||
private func translationButton(visible: Bool) -> some View {
|
|
||||||
KeyboardTranslationMenuButton(
|
|
||||||
palette: palette,
|
|
||||||
targetLocaleId: state.translationTargetLocaleId,
|
|
||||||
onSelect: state.setTranslationTargetLocaleId
|
|
||||||
)
|
|
||||||
.equatable()
|
|
||||||
.frame(
|
|
||||||
width: KeyboardLayoutMetrics.undoButtonSize,
|
|
||||||
height: KeyboardLayoutMetrics.undoButtonSize
|
|
||||||
)
|
|
||||||
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
|
|
||||||
.opacity(visible ? 1 : 0)
|
|
||||||
.allowsHitTesting(visible)
|
|
||||||
.accessibilityHidden(!visible)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shouldShowClipboardSuggestion: Bool {
|
|
||||||
guard state.canShowClipboardEntry else { return false }
|
|
||||||
guard let text = state.clipboardSuggestionText, !text.isEmpty else { return false }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func bottomSpaceButton(disabled: Bool) -> some View {
|
|
||||||
RectangularToolbarButton(
|
|
||||||
spaceStyle: true,
|
|
||||||
label: "space",
|
|
||||||
disabled: disabled,
|
|
||||||
hapticIntensity: state.keyboardHapticIntensity
|
|
||||||
) {
|
|
||||||
state.insertSpace()
|
|
||||||
}
|
|
||||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func bottomReturnButton(disabled: Bool) -> some View {
|
|
||||||
let title = ExtL10n.string(state.returnKeyRole.titleKey)
|
|
||||||
return RectangularToolbarButton(
|
|
||||||
title: title,
|
|
||||||
label: title,
|
|
||||||
disabled: disabled,
|
|
||||||
isSend: state.returnKeyRole.usesActionFill,
|
|
||||||
hapticIntensity: state.keyboardHapticIntensity
|
|
||||||
) {
|
|
||||||
state.insertNewline()
|
|
||||||
}
|
|
||||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Option C: block typing keys during the full voice-input pipeline.
|
|
||||||
private var voiceInputBlocksEditing: Bool {
|
|
||||||
switch state.phase {
|
|
||||||
case .requestingPermissions, .recording, .processing:
|
|
||||||
return true
|
|
||||||
case .idle, .error, .denied:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var buttonPhase: RecordButton.Phase {
|
|
||||||
switch state.micVoiceAvailability {
|
|
||||||
case .recording:
|
|
||||||
return .recording
|
|
||||||
case .processing:
|
|
||||||
return .processing
|
|
||||||
case .ready, .unavailable:
|
|
||||||
// Host/PiP readiness is handled by an automatic app handoff. Keep
|
|
||||||
// the idle mic visually green instead of exposing startup state.
|
|
||||||
return .idleReady
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disabled only when the shared voice prerequisites are unavailable.
|
|
||||||
private var micButtonEnabled: Bool {
|
|
||||||
if state.micDisabled { return false }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - State alias
|
|
||||||
|
|
||||||
extension KeyboardRootView {
|
|
||||||
typealias State = KeyboardViewController.State
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - SwiftUI Preview
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
#Preview("Keyboard · Idle") {
|
|
||||||
KeyboardRootView(
|
|
||||||
state: KeyboardViewController.State.previewIdle,
|
|
||||||
typing: TypingSessionController()
|
|
||||||
)
|
|
||||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Keyboard · Recording") {
|
|
||||||
KeyboardRootView(
|
|
||||||
state: KeyboardViewController.State.previewRecording,
|
|
||||||
typing: TypingSessionController()
|
|
||||||
)
|
|
||||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview("Keyboard · Processing") {
|
|
||||||
KeyboardRootView(
|
|
||||||
state: KeyboardViewController.State.previewProcessing,
|
|
||||||
typing: TypingSessionController()
|
|
||||||
)
|
|
||||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Transcript line
|
|
||||||
|
|
||||||
private struct TranscriptLine: View {
|
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
|
||||||
|
|
||||||
let phase: KeyboardViewController.State.Phase
|
|
||||||
let transcript: String
|
|
||||||
let micVoiceAvailability: MicVoiceAvailability
|
|
||||||
let micDisabledHint: String
|
|
||||||
let editHint: String?
|
|
||||||
let editHintIsPositive: Bool
|
|
||||||
let cursorDragHintActive: Bool
|
|
||||||
let openSettings: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
// While dragging the caret, the whole mic cluster + transcript
|
|
||||||
// line give way to the cursor-drag overlay, so hide this line's
|
|
||||||
// "点按说话" / status text entirely.
|
|
||||||
if !cursorDragHintActive {
|
|
||||||
phaseContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.horizontal, Spacing.md)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var phaseContent: some View {
|
|
||||||
switch phase {
|
|
||||||
case .idle:
|
|
||||||
idleHint
|
|
||||||
case .requestingPermissions:
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
ProgressView().controlSize(.mini).tint(palette.textSecondary)
|
|
||||||
Text(
|
|
||||||
transcript.isEmpty
|
|
||||||
? ExtL10n.string("keyboard.placeholder.preparing")
|
|
||||||
: transcript
|
|
||||||
)
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
}
|
|
||||||
case .recording:
|
|
||||||
Text(transcript.isEmpty ? " " : transcript)
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.head)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
case .processing:
|
|
||||||
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
case .error(_, let msg):
|
|
||||||
Text(msg ?? "")
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.warning)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
case .denied(let reason):
|
|
||||||
Button(action: openSettings) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text(deniedMessage(for: reason))
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 10, weight: .semibold))
|
|
||||||
}
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.warning)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var idleHint: some View {
|
|
||||||
if let editHint, !editHint.isEmpty {
|
|
||||||
Text(editHint)
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(editHintIsPositive ? palette.accent : palette.warning)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
} else {
|
|
||||||
let isWarning: Bool = {
|
|
||||||
switch micVoiceAvailability {
|
|
||||||
case .unavailable(.hostNotReady), .unavailable(.preparingSession):
|
|
||||||
return false
|
|
||||||
case .unavailable:
|
|
||||||
return true
|
|
||||||
case .ready, .recording, .processing:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
Group {
|
|
||||||
switch micVoiceAvailability {
|
|
||||||
case .ready:
|
|
||||||
// Soft tip when polish key is missing but local ASR can still run.
|
|
||||||
if !micDisabledHint.isEmpty {
|
|
||||||
Text(micDisabledHint)
|
|
||||||
} else {
|
|
||||||
ExtL10n.text("keyboard.placeholder.idle")
|
|
||||||
}
|
|
||||||
case .unavailable(.missingAPIKey):
|
|
||||||
Text(micDisabledHint.isEmpty
|
|
||||||
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
|
||||||
: micDisabledHint)
|
|
||||||
case .unavailable(.hostNotReady):
|
|
||||||
ExtL10n.text("keyboard.placeholder.idle")
|
|
||||||
case .unavailable(.preparingSession):
|
|
||||||
ExtL10n.text("keyboard.placeholder.idle")
|
|
||||||
case .unavailable(.noFullAccess):
|
|
||||||
ExtL10n.text("keyboard.error.fullAccessRequired")
|
|
||||||
case .unavailable(.appGroupUnavailable):
|
|
||||||
ExtL10n.text("keyboard.error.appGroupCommunication")
|
|
||||||
case .unavailable(.onboardingIncomplete):
|
|
||||||
ExtL10n.text("keyboard.hint.finishSetupInApp")
|
|
||||||
case .recording, .processing:
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(
|
|
||||||
(isWarning || (micVoiceAvailability.isReady && !micDisabledHint.isEmpty))
|
|
||||||
? palette.warning
|
|
||||||
: palette.textTertiary
|
|
||||||
)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
|
||||||
switch reason {
|
|
||||||
case .mic: return ExtL10n.string("keyboard.denied.mic")
|
|
||||||
case .speech: return ExtL10n.string("keyboard.denied.speech")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,8 +25,8 @@ enum KeyboardTopBarMetrics {
|
|||||||
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
|
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
|
||||||
static let logoHeight: CGFloat = 16
|
static let logoHeight: CGFloat = 16
|
||||||
static let logoWidth: CGFloat = logoHeight * 952 / 291
|
static let logoWidth: CGFloat = logoHeight * 952 / 291
|
||||||
/// Equal hit width for AI / Voice / Chinese / English input tabs.
|
/// Equal hit width for Assistant / Chinese / English input tabs.
|
||||||
static let inputTabWidth: CGFloat = 42
|
static let inputTabWidth: CGFloat = 48
|
||||||
/// Shared footprint for top-trailing chips (clipboard, cancel/X, translation).
|
/// Shared footprint for top-trailing chips (clipboard, cancel/X, translation).
|
||||||
static let trailingChipSize: CGFloat = 34
|
static let trailingChipSize: CGFloat = 34
|
||||||
static let trailingChipIconSize: CGFloat = 15
|
static let trailingChipIconSize: CGFloat = 15
|
||||||
@@ -63,6 +63,7 @@ struct KeyboardCancelButton: View {
|
|||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
let accessibilityLabel: Text
|
let accessibilityLabel: Text
|
||||||
let accessibilityHint: Text
|
let accessibilityHint: Text
|
||||||
|
var accessibilityIdentifier = "keyboard.cancel"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Same 34×34 chip as KeyboardClipboardMenuButton / translation.
|
// Same 34×34 chip as KeyboardClipboardMenuButton / translation.
|
||||||
@@ -82,21 +83,20 @@ struct KeyboardCancelButton: View {
|
|||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(accessibilityIdentifier)
|
||||||
.accessibilityLabel(accessibilityLabel)
|
.accessibilityLabel(accessibilityLabel)
|
||||||
.accessibilityHint(accessibilityHint)
|
.accessibilityHint(accessibilityHint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum KeyboardInputTab: CaseIterable {
|
private enum KeyboardInputTab: CaseIterable {
|
||||||
case ai
|
case assistant
|
||||||
case voice
|
|
||||||
case chinese
|
case chinese
|
||||||
case english
|
case english
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .ai: return ExtL10n.string("keyboard.tab.ai")
|
case .assistant: return ExtL10n.string("keyboard.tab.ai")
|
||||||
case .voice: return ExtL10n.string("keyboard.tab.voice")
|
|
||||||
case .chinese: return ExtL10n.string("keyboard.tab.chinese")
|
case .chinese: return ExtL10n.string("keyboard.tab.chinese")
|
||||||
case .english: return ExtL10n.string("keyboard.tab.english")
|
case .english: return ExtL10n.string("keyboard.tab.english")
|
||||||
}
|
}
|
||||||
@@ -117,9 +117,17 @@ struct KeyboardTopControls: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
inputTabSwitcher
|
inputTabSwitcher
|
||||||
if state.canShowClipboardEntry {
|
HStack(spacing: 6) {
|
||||||
HStack {
|
Spacer(minLength: 0)
|
||||||
Spacer(minLength: 0)
|
if state.surface != .typing {
|
||||||
|
KeyboardTranslationMenuButton(
|
||||||
|
palette: palette,
|
||||||
|
targetLocaleId: state.translationTargetLocaleId,
|
||||||
|
onSelect: state.setTranslationTargetLocaleId
|
||||||
|
)
|
||||||
|
.equatable()
|
||||||
|
}
|
||||||
|
if state.canShowClipboardEntry {
|
||||||
KeyboardClipboardMenuButton(
|
KeyboardClipboardMenuButton(
|
||||||
palette: palette,
|
palette: palette,
|
||||||
action: state.openClipboardPanel
|
action: state.openClipboardPanel
|
||||||
@@ -156,8 +164,9 @@ struct KeyboardTopControls: View {
|
|||||||
tabLabel(tab, selected: selected)
|
tabLabel(tab, selected: selected)
|
||||||
}
|
}
|
||||||
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
|
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
|
||||||
.disabled(tab != .voice && !state.canEnterTypingSurface)
|
.disabled(tab != .assistant && !state.canEnterTypingSurface)
|
||||||
.opacity(tabOpacity(tab))
|
.opacity(tabOpacity(tab))
|
||||||
|
.accessibilityIdentifier("assistant.tab.\(tabIdentifier(tab))")
|
||||||
.accessibilityLabel(accessibilityLabel(for: tab))
|
.accessibilityLabel(accessibilityLabel(for: tab))
|
||||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||||
}
|
}
|
||||||
@@ -187,11 +196,8 @@ struct KeyboardTopControls: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func tabContent(_ tab: KeyboardInputTab, selected: Bool) -> some View {
|
private func tabContent(_ tab: KeyboardInputTab, selected: Bool) -> some View {
|
||||||
if tab == .ai {
|
if tab == .assistant {
|
||||||
Image(systemName: "sparkle")
|
Image(systemName: "sparkles")
|
||||||
.font(.system(size: 15, weight: selected ? .semibold : .medium))
|
|
||||||
} else if tab == .voice {
|
|
||||||
Image(systemName: "waveform.mid")
|
|
||||||
.font(.system(size: 15, weight: selected ? .semibold : .medium))
|
.font(.system(size: 15, weight: selected ? .semibold : .medium))
|
||||||
} else {
|
} else {
|
||||||
Text(tab.title)
|
Text(tab.title)
|
||||||
@@ -200,13 +206,21 @@ struct KeyboardTopControls: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
|
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
|
||||||
guard tab != .voice, !state.canEnterTypingSurface else { return 1 }
|
guard tab != .assistant, !state.canEnterTypingSurface else { return 1 }
|
||||||
if case .recording = state.phase {
|
if case .recording = state.phase {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return 0.42
|
return 0.42
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func tabIdentifier(_ tab: KeyboardInputTab) -> String {
|
||||||
|
switch tab {
|
||||||
|
case .assistant: return "assistant"
|
||||||
|
case .chinese: return "chinese"
|
||||||
|
case .english: return "english"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var pressedFill: Color {
|
private var pressedFill: Color {
|
||||||
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
|
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
|
||||||
}
|
}
|
||||||
@@ -219,10 +233,8 @@ struct KeyboardTopControls: View {
|
|||||||
|
|
||||||
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
|
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
|
||||||
switch tab {
|
switch tab {
|
||||||
case .ai:
|
case .assistant:
|
||||||
return state.surface == .ai
|
return state.surface == .voice || state.surface == .ai
|
||||||
case .voice:
|
|
||||||
return state.surface == .voice
|
|
||||||
case .chinese:
|
case .chinese:
|
||||||
return state.surface == .typing && typing.language == .chinese
|
return state.surface == .typing && typing.language == .chinese
|
||||||
case .english:
|
case .english:
|
||||||
@@ -232,9 +244,7 @@ struct KeyboardTopControls: View {
|
|||||||
|
|
||||||
private func select(_ tab: KeyboardInputTab) {
|
private func select(_ tab: KeyboardInputTab) {
|
||||||
switch tab {
|
switch tab {
|
||||||
case .ai:
|
case .assistant:
|
||||||
state.setSurface(.ai)
|
|
||||||
case .voice:
|
|
||||||
state.setSurface(.voice)
|
state.setSurface(.voice)
|
||||||
case .chinese:
|
case .chinese:
|
||||||
applyTypingOutput(typing.setLanguage(.chinese))
|
applyTypingOutput(typing.setLanguage(.chinese))
|
||||||
@@ -262,8 +272,7 @@ struct KeyboardTopControls: View {
|
|||||||
|
|
||||||
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
|
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
|
||||||
switch tab {
|
switch tab {
|
||||||
case .ai: return ExtL10n.string("keyboard.tab.ai.a11y")
|
case .assistant: return ExtL10n.string("keyboard.tab.ai.a11y")
|
||||||
case .voice: return ExtL10n.string("keyboard.tab.voice.a11y")
|
|
||||||
case .chinese: return ExtL10n.string("keyboard.tab.chinese.a11y")
|
case .chinese: return ExtL10n.string("keyboard.tab.chinese.a11y")
|
||||||
case .english: return ExtL10n.string("keyboard.tab.english.a11y")
|
case .english: return ExtL10n.string("keyboard.tab.english.a11y")
|
||||||
}
|
}
|
||||||
@@ -271,6 +280,8 @@ struct KeyboardTopControls: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct KeyboardTranslationMenuButton: View, Equatable {
|
struct KeyboardTranslationMenuButton: View, Equatable {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
let palette: ThemePalette
|
let palette: ThemePalette
|
||||||
let targetLocaleId: String
|
let targetLocaleId: String
|
||||||
let onSelect: (String) -> Void
|
let onSelect: (String) -> Void
|
||||||
@@ -310,10 +321,16 @@ struct KeyboardTranslationMenuButton: View, Equatable {
|
|||||||
: palette.textSecondary
|
: palette.textSecondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.frame(
|
||||||
|
width: KeyboardTopBarMetrics.trailingChipSize,
|
||||||
|
height: KeyboardTopBarMetrics.trailingChipSize
|
||||||
|
)
|
||||||
|
.background(NativeKeyboardKeyColors.fill(for: colorScheme), in: Circle())
|
||||||
|
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
.glassEffect(.regular.interactive(), in: Circle())
|
|
||||||
}
|
}
|
||||||
.menuStyle(.button)
|
.menuStyle(.button)
|
||||||
|
.accessibilityIdentifier("assistant.translation")
|
||||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
|
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
|
||||||
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
|
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import OSGKeyboardShared
|
|||||||
|
|
||||||
struct LastInputEditView: View {
|
struct LastInputEditView: View {
|
||||||
private enum Layout {
|
private enum Layout {
|
||||||
static let primaryButtonHeight: CGFloat = 50
|
static let primaryButtonHeight = KeyboardChromeLayout.assistantActionCapsuleHeight
|
||||||
static let primaryButtonWidth: CGFloat = primaryButtonHeight * 3
|
static let primaryButtonWidth: CGFloat = primaryButtonHeight * 3
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +139,7 @@ struct LastInputEditView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(primaryDisabled)
|
.disabled(primaryDisabled)
|
||||||
|
.accessibilityIdentifier(primaryAccessibilityIdentifier)
|
||||||
.accessibilityLabel(Text(primaryAccessibilityLabel))
|
.accessibilityLabel(Text(primaryAccessibilityLabel))
|
||||||
helperText(rightHelper)
|
helperText(rightHelper)
|
||||||
}
|
}
|
||||||
@@ -239,6 +240,17 @@ struct LastInputEditView: View {
|
|||||||
return ExtL10n.string("keyboard.edit.stop")
|
return ExtL10n.string("keyboard.edit.stop")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var primaryAccessibilityIdentifier: String {
|
||||||
|
switch state.editSession {
|
||||||
|
case .listening:
|
||||||
|
return "assistant.edit.stop"
|
||||||
|
case .review:
|
||||||
|
return "assistant.edit.confirm"
|
||||||
|
default:
|
||||||
|
return "assistant.edit.primary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var reviewSwipeGesture: some Gesture {
|
private var reviewSwipeGesture: some Gesture {
|
||||||
DragGesture(minimumDistance: 12)
|
DragGesture(minimumDistance: 12)
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ struct RectangularToolbarButton: View {
|
|||||||
let usesCircleGlass: Bool
|
let usesCircleGlass: Bool
|
||||||
/// Settings → General → Haptics; space / return use `.action` role.
|
/// Settings → General → Haptics; space / return use `.action` role.
|
||||||
var hapticIntensity: KeyboardHapticIntensity = .off
|
var hapticIntensity: KeyboardHapticIntensity = .off
|
||||||
|
var onPressingChanged: (Bool) -> Void = { _ in }
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -215,6 +216,7 @@ struct RectangularToolbarButton: View {
|
|||||||
usesLiquidGlass: Bool = false,
|
usesLiquidGlass: Bool = false,
|
||||||
usesCircleGlass: Bool = false,
|
usesCircleGlass: Bool = false,
|
||||||
hapticIntensity: KeyboardHapticIntensity = .off,
|
hapticIntensity: KeyboardHapticIntensity = .off,
|
||||||
|
onPressingChanged: @escaping (Bool) -> Void = { _ in },
|
||||||
action: @escaping () -> Void
|
action: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
self.systemName = systemName
|
self.systemName = systemName
|
||||||
@@ -226,6 +228,7 @@ struct RectangularToolbarButton: View {
|
|||||||
self.usesLiquidGlass = usesLiquidGlass
|
self.usesLiquidGlass = usesLiquidGlass
|
||||||
self.usesCircleGlass = usesCircleGlass
|
self.usesCircleGlass = usesCircleGlass
|
||||||
self.hapticIntensity = hapticIntensity
|
self.hapticIntensity = hapticIntensity
|
||||||
|
self.onPressingChanged = onPressingChanged
|
||||||
self.action = action
|
self.action = action
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +239,7 @@ struct RectangularToolbarButton: View {
|
|||||||
isSend: Bool = false,
|
isSend: Bool = false,
|
||||||
usesLiquidGlass: Bool = false,
|
usesLiquidGlass: Bool = false,
|
||||||
hapticIntensity: KeyboardHapticIntensity = .off,
|
hapticIntensity: KeyboardHapticIntensity = .off,
|
||||||
|
onPressingChanged: @escaping (Bool) -> Void = { _ in },
|
||||||
action: @escaping () -> Void
|
action: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
self.systemName = nil
|
self.systemName = nil
|
||||||
@@ -246,6 +250,7 @@ struct RectangularToolbarButton: View {
|
|||||||
self.usesLiquidGlass = usesLiquidGlass
|
self.usesLiquidGlass = usesLiquidGlass
|
||||||
self.usesCircleGlass = false
|
self.usesCircleGlass = false
|
||||||
self.hapticIntensity = hapticIntensity
|
self.hapticIntensity = hapticIntensity
|
||||||
|
self.onPressingChanged = onPressingChanged
|
||||||
self.action = action
|
self.action = action
|
||||||
self.title = title
|
self.title = title
|
||||||
}
|
}
|
||||||
@@ -256,6 +261,7 @@ struct RectangularToolbarButton: View {
|
|||||||
disabled: Bool = false,
|
disabled: Bool = false,
|
||||||
usesLiquidGlass: Bool = false,
|
usesLiquidGlass: Bool = false,
|
||||||
hapticIntensity: KeyboardHapticIntensity = .off,
|
hapticIntensity: KeyboardHapticIntensity = .off,
|
||||||
|
onPressingChanged: @escaping (Bool) -> Void = { _ in },
|
||||||
action: @escaping () -> Void
|
action: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
self.systemName = nil
|
self.systemName = nil
|
||||||
@@ -267,6 +273,7 @@ struct RectangularToolbarButton: View {
|
|||||||
self.usesLiquidGlass = usesLiquidGlass
|
self.usesLiquidGlass = usesLiquidGlass
|
||||||
self.usesCircleGlass = false
|
self.usesCircleGlass = false
|
||||||
self.hapticIntensity = hapticIntensity
|
self.hapticIntensity = hapticIntensity
|
||||||
|
self.onPressingChanged = onPressingChanged
|
||||||
self.action = action
|
self.action = action
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +287,11 @@ struct RectangularToolbarButton: View {
|
|||||||
.allowsHitTesting(!disabled)
|
.allowsHitTesting(!disabled)
|
||||||
.accessibilityLabel(Text(label))
|
.accessibilityLabel(Text(label))
|
||||||
.accessibilityAddTraits(.isButton)
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.onDisappear {
|
||||||
|
guard isPressing else { return }
|
||||||
|
isPressing = false
|
||||||
|
onPressingChanged(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -337,12 +349,14 @@ struct RectangularToolbarButton: View {
|
|||||||
.onChanged { _ in
|
.onChanged { _ in
|
||||||
guard !disabled, !isPressing else { return }
|
guard !disabled, !isPressing else { return }
|
||||||
isPressing = true
|
isPressing = true
|
||||||
|
onPressingChanged(true)
|
||||||
KeyboardSoundFeedback.keyClick()
|
KeyboardSoundFeedback.keyClick()
|
||||||
KeyboardHapticFeedback.play(role: .action, intensity: hapticIntensity)
|
KeyboardHapticFeedback.play(role: .action, intensity: hapticIntensity)
|
||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
isPressing = false
|
isPressing = false
|
||||||
|
onPressingChanged(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,19 @@
|
|||||||
"keyboard.ai.sendA11y" = "Tap once to insert the AI answer, then tap again to send in supported fields";
|
"keyboard.ai.sendA11y" = "Tap once to insert the AI answer, then tap again to send in supported fields";
|
||||||
"keyboard.ai.startA11y" = "Start asking AI";
|
"keyboard.ai.startA11y" = "Start asking AI";
|
||||||
"keyboard.ai.stopA11y" = "Finish the question and send it to AI";
|
"keyboard.ai.stopA11y" = "Finish the question and send it to AI";
|
||||||
|
"keyboard.assistant.longPressCoach" = "Tap to dictate · Hold to ask AI";
|
||||||
|
"keyboard.assistant.micA11y" = "Voice and AI";
|
||||||
|
"keyboard.assistant.micHint" = "Tap to dictate. Hold to ask AI.";
|
||||||
|
"keyboard.assistant.dictationAction" = "Start voice dictation";
|
||||||
|
"keyboard.assistant.aiAction" = "Ask AI";
|
||||||
|
"keyboard.assistant.space" = "Space";
|
||||||
|
"keyboard.assistant.delete" = "Delete";
|
||||||
|
"keyboard.assistant.sendHint" = "Performs the current field's Send, Search, Done, or Go action.";
|
||||||
|
"keyboard.assistant.insertPending" = "Insert retained AI answer";
|
||||||
|
"keyboard.assistant.discardPending" = "Discard AI answer";
|
||||||
|
"keyboard.assistant.discardPendingHint" = "Discard the answer retained because the cursor or input field changed.";
|
||||||
|
"keyboard.assistant.dismissClipboard" = "Dismiss clipboard suggestions";
|
||||||
|
"keyboard.assistant.dismissClipboardHint" = "Hide the current clipboard summary and skills.";
|
||||||
"keyboard.ai.error.missingAPIKey" = "Configure an AI service in the main app first";
|
"keyboard.ai.error.missingAPIKey" = "Configure an AI service in the main app first";
|
||||||
"keyboard.ai.error.pipelineBusy" = "Voice input is busy. Try again shortly";
|
"keyboard.ai.error.pipelineBusy" = "Voice input is busy. Try again shortly";
|
||||||
"keyboard.ai.error.startTimeout" = "Microphone startup timed out. Try again";
|
"keyboard.ai.error.startTimeout" = "Microphone startup timed out. Try again";
|
||||||
|
|||||||
@@ -296,6 +296,19 @@
|
|||||||
"keyboard.ai.sendA11y" = "首次点击插入 AI 回答;在支持发送的输入框中再次点击发送";
|
"keyboard.ai.sendA11y" = "首次点击插入 AI 回答;在支持发送的输入框中再次点击发送";
|
||||||
"keyboard.ai.startA11y" = "开始向 AI 提问";
|
"keyboard.ai.startA11y" = "开始向 AI 提问";
|
||||||
"keyboard.ai.stopA11y" = "结束提问并发送给 AI";
|
"keyboard.ai.stopA11y" = "结束提问并发送给 AI";
|
||||||
|
"keyboard.assistant.longPressCoach" = "轻点听写 · 长按问 AI";
|
||||||
|
"keyboard.assistant.micA11y" = "语音与 AI";
|
||||||
|
"keyboard.assistant.micHint" = "轻点开始语音听写,长按向 AI 提问。";
|
||||||
|
"keyboard.assistant.dictationAction" = "开始语音听写";
|
||||||
|
"keyboard.assistant.aiAction" = "向 AI 提问";
|
||||||
|
"keyboard.assistant.space" = "空格";
|
||||||
|
"keyboard.assistant.delete" = "删除";
|
||||||
|
"keyboard.assistant.sendHint" = "执行当前输入框的发送、搜索、完成或前往操作。";
|
||||||
|
"keyboard.assistant.insertPending" = "插入保留的 AI 回答";
|
||||||
|
"keyboard.assistant.discardPending" = "丢弃 AI 回答";
|
||||||
|
"keyboard.assistant.discardPendingHint" = "丢弃因光标或输入框变化而保留的回答。";
|
||||||
|
"keyboard.assistant.dismissClipboard" = "关闭剪贴板建议";
|
||||||
|
"keyboard.assistant.dismissClipboardHint" = "隐藏当前剪贴板摘要和技能。";
|
||||||
"keyboard.ai.error.missingAPIKey" = "请先在主 App 配置可用的 AI 服务";
|
"keyboard.ai.error.missingAPIKey" = "请先在主 App 配置可用的 AI 服务";
|
||||||
"keyboard.ai.error.pipelineBusy" = "语音服务正忙,请稍后重试";
|
"keyboard.ai.error.pipelineBusy" = "语音服务正忙,请稍后重试";
|
||||||
"keyboard.ai.error.startTimeout" = "麦克风启动超时,请重试";
|
"keyboard.ai.error.startTimeout" = "麦克风启动超时,请重试";
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ final class RimeSchemaGeneratorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testAISurfaceRestoresAsEmptyModeWithoutGeneralRememberSetting() {
|
func testLegacyAISurfaceMigratesToUnifiedAssistant() {
|
||||||
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
|
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||||
@@ -187,7 +187,7 @@ final class RimeSchemaGeneratorTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
|
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
|
||||||
.ai
|
.voice
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ public struct SevenDayUsageChart: View {
|
|||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .top) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(SharedL10n.string("stat.weekChart.title", language: language).uppercased())
|
Text(SharedL10n.string("stat.weekChart.title", language: language).uppercased())
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ enum MacSystemPalette {
|
|||||||
accent: Palette.accent,
|
accent: Palette.accent,
|
||||||
accentMuted: Palette.accent.opacity(dark ? 0.22 : 0.14),
|
accentMuted: Palette.accent.opacity(dark ? 0.22 : 0.14),
|
||||||
accentGlow: Palette.accent.opacity(dark ? 0.40 : 0.32),
|
accentGlow: Palette.accent.opacity(dark ? 0.40 : 0.32),
|
||||||
|
aiTeal: Palette.aiTeal,
|
||||||
|
|
||||||
danger: resolved(.systemRed, dark: dark),
|
danger: resolved(.systemRed, dark: dark),
|
||||||
success: Palette.accent,
|
success: Palette.accent,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ public struct ThemePalette: Sendable, Equatable {
|
|||||||
public let accent: Color
|
public let accent: Color
|
||||||
public let accentMuted: Color
|
public let accentMuted: Color
|
||||||
public let accentGlow: Color
|
public let accentGlow: Color
|
||||||
|
/// Distinguishes AI listening / generation from ordinary dictation.
|
||||||
|
public let aiTeal: Color
|
||||||
|
|
||||||
public let danger: Color
|
public let danger: Color
|
||||||
public let success: Color
|
public let success: Color
|
||||||
@@ -54,6 +56,7 @@ public enum Palette {
|
|||||||
public static let accent = Color(red: 0.227, green: 0.627, blue: 0.353) // #3AA05A
|
public static let accent = Color(red: 0.227, green: 0.627, blue: 0.353) // #3AA05A
|
||||||
public static let accentMuted = accent.opacity(0.18)
|
public static let accentMuted = accent.opacity(0.18)
|
||||||
public static let accentGlow = accent.opacity(0.42)
|
public static let accentGlow = accent.opacity(0.42)
|
||||||
|
public static let aiTeal = Color(red: 0.169, green: 0.686, blue: 0.643) // #2BAFA4
|
||||||
|
|
||||||
// Semantic
|
// Semantic
|
||||||
public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A
|
public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A
|
||||||
@@ -86,6 +89,7 @@ public enum Palette {
|
|||||||
accent: accent,
|
accent: accent,
|
||||||
accentMuted: accentMuted,
|
accentMuted: accentMuted,
|
||||||
accentGlow: accentGlow,
|
accentGlow: accentGlow,
|
||||||
|
aiTeal: aiTeal,
|
||||||
danger: danger,
|
danger: danger,
|
||||||
success: success,
|
success: success,
|
||||||
warning: warning,
|
warning: warning,
|
||||||
@@ -108,6 +112,7 @@ public enum Palette {
|
|||||||
accent: Color(red: 0.227, green: 0.627, blue: 0.353), // #3AA05A
|
accent: Color(red: 0.227, green: 0.627, blue: 0.353), // #3AA05A
|
||||||
accentMuted: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.14),
|
accentMuted: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.14),
|
||||||
accentGlow: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.32),
|
accentGlow: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.32),
|
||||||
|
aiTeal: Color(red: 0.169, green: 0.686, blue: 0.643), // #2BAFA4
|
||||||
danger: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
|
danger: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
|
||||||
success: Color(red: 0.227, green: 0.627, blue: 0.353), // same as accent
|
success: Color(red: 0.227, green: 0.627, blue: 0.353), // same as accent
|
||||||
warning: Color(red: 1.000, green: 0.620, blue: 0.094), // #FF9E18
|
warning: Color(red: 1.000, green: 0.620, blue: 0.094), // #FF9E18
|
||||||
|
|||||||
@@ -190,6 +190,18 @@ public struct AISessionState: Equatable, Sendable {
|
|||||||
phase = .sent
|
phase = .sent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drops a result retained because the insertion target changed.
|
||||||
|
/// The conversation remains active so the user can immediately ask again.
|
||||||
|
public mutating func discardReadyAnswer() {
|
||||||
|
guard phase == .ready else { return }
|
||||||
|
answer = nil
|
||||||
|
activeUtteranceID = nil
|
||||||
|
draftAnswerText = nil
|
||||||
|
transcript = ""
|
||||||
|
errorMessage = nil
|
||||||
|
phase = .idle
|
||||||
|
}
|
||||||
|
|
||||||
public mutating func cancelCurrentWork() {
|
public mutating func cancelCurrentWork() {
|
||||||
guard isBusy else { return }
|
guard isBusy else { return }
|
||||||
activeUtteranceID = nil
|
activeUtteranceID = nil
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ public struct EditableInputReference: Codable, Equatable, Sendable {
|
|||||||
now >= expiresAt
|
now >= expiresAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same-process verification uses the exact insertion kept in memory.
|
||||||
|
/// This path intentionally does not depend on the field fingerprint,
|
||||||
|
/// because UITextDocumentProxy may publish that context one callback after
|
||||||
|
/// the insertion while the in-memory record is already authoritative.
|
||||||
|
public func matchesLiveInsertion(
|
||||||
|
extensionInstanceID currentInstanceID: UUID,
|
||||||
|
lastInsertedText: String?,
|
||||||
|
contextBeforeInput: String?
|
||||||
|
) -> Bool {
|
||||||
|
!isExpired()
|
||||||
|
&& isWithinLengthBudget
|
||||||
|
&& extensionInstanceID == currentInstanceID
|
||||||
|
&& lastInsertedText == insertedText
|
||||||
|
&& contextBeforeInput?.hasSuffix(insertedText) == true
|
||||||
|
}
|
||||||
|
|
||||||
/// Rebuilt extensions must match the complete inserted string at the caret
|
/// Rebuilt extensions must match the complete inserted string at the caret
|
||||||
/// and, when captured, the same field fingerprint; a suffix sample is insufficient.
|
/// and, when captured, the same field fingerprint; a suffix sample is insufficient.
|
||||||
public func isFullyVerified(
|
public func isFullyVerified(
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import CoreGraphics
|
|||||||
public enum KeyboardChromeLayout {
|
public enum KeyboardChromeLayout {
|
||||||
public static let totalHeight: CGFloat = 281
|
public static let totalHeight: CGFloat = 281
|
||||||
public static let actionKeyHeight: CGFloat = 50
|
public static let actionKeyHeight: CGFloat = 50
|
||||||
|
/// Shared capsule action height for unified Send and edit-mode controls.
|
||||||
|
public static let assistantActionCapsuleHeight: CGFloat = actionKeyHeight
|
||||||
public static let actionKeyCornerRadius: CGFloat = 10
|
public static let actionKeyCornerRadius: CGFloat = 10
|
||||||
/// Shared spacing for every bottom action row. iPad uses a custom globe
|
/// Shared spacing for every bottom action row. iPad uses a custom globe
|
||||||
/// slot; iPhone relies on the system-provided switch below the keyboard.
|
/// slot; iPhone relies on the system-provided switch below the keyboard.
|
||||||
|
|||||||
@@ -216,15 +216,16 @@ public final class TypingInputConfiguration: ObservableObject {
|
|||||||
let store = defaults ?? AppGroup.defaultsIfAvailable
|
let store = defaults ?? AppGroup.defaultsIfAvailable
|
||||||
guard let store else { return (.voice, nil) }
|
guard let store else { return (.voice, nil) }
|
||||||
|
|
||||||
// AI is an explicit product surface. Restore it as an empty temporary
|
// Older builds persisted a dedicated AI surface. It now migrates to
|
||||||
// conversation even when the general "remember surface" toggle is off.
|
// the unified assistant surface.
|
||||||
if store.string(forKey: Key.lastSurface) == KeyboardState.Surface.ai.rawValue {
|
if store.string(forKey: Key.lastSurface) == KeyboardState.Surface.ai.rawValue {
|
||||||
return (.ai, nil)
|
return (.voice, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if store.bool(forKey: Key.rememberLastSurface),
|
if store.bool(forKey: Key.rememberLastSurface),
|
||||||
let raw = store.string(forKey: Key.lastSurface),
|
let raw = store.string(forKey: Key.lastSurface),
|
||||||
let surface = KeyboardState.Surface(rawValue: raw) {
|
let persistedSurface = KeyboardState.Surface(rawValue: raw) {
|
||||||
|
let surface: KeyboardState.Surface = persistedSurface == .ai ? .voice : persistedSurface
|
||||||
let language: TypingInputLanguage? = surface == .typing
|
let language: TypingInputLanguage? = surface == .typing
|
||||||
? persistedTypingLanguage(defaults: store) ?? .chinese
|
? persistedTypingLanguage(defaults: store) ?? .chinese
|
||||||
: nil
|
: nil
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ public enum AIQuestionPromptComposer {
|
|||||||
languageInstruction = "Reply in the language used by the user's latest question."
|
languageInstruction = "Reply in the language used by the user's latest question."
|
||||||
} else {
|
} else {
|
||||||
let language = TranslationLanguageCatalog.resolve(targetLocaleID)
|
let language = TranslationLanguageCatalog.resolve(targetLocaleID)
|
||||||
languageInstruction = "Reply in \(language.promptLanguageName)."
|
languageInstruction = """
|
||||||
|
Reply in \(language.promptLanguageName), unless the user's latest
|
||||||
|
request explicitly asks for another output language or asks to
|
||||||
|
preserve the source language.
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
return """
|
return """
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
// CursorNavigation.swift
|
|
||||||
// OSGKeyboard · Shared
|
|
||||||
//
|
|
||||||
// Pure helpers for moving the text caret from the keyboard extension.
|
|
||||||
// Horizontal moves are character-accurate. Vertical moves jump between
|
|
||||||
// *visual* lines — hard `\n` breaks and soft wraps.
|
|
||||||
//
|
|
||||||
// First-principles note: a keyboard extension only sees a bounded text
|
|
||||||
// window (`documentContext{Before,After}Input`) and can only actuate via
|
|
||||||
// `adjustTextPosition(byCharacterOffset:)`. It has NO access to the host
|
|
||||||
// field's font, width, or caret rect, so soft-wrap positions are
|
|
||||||
// fundamentally unknowable and must be *estimated*. We reduce the visible
|
|
||||||
// error two ways: (1) exact handling of hard `\n`; (2) an injectable
|
|
||||||
// per-character width so the extension can feed real font metrics (killing
|
|
||||||
// the i-vs-W column drift that a fixed 1/2 table causes). The wrap width
|
|
||||||
// itself stays a calibrated estimate.
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreGraphics
|
|
||||||
|
|
||||||
public enum CursorNavigation {
|
|
||||||
|
|
||||||
/// Advance width of a single character, in an arbitrary but consistent
|
|
||||||
/// unit (points when backed by real font metrics; abstract "units" for
|
|
||||||
/// the built-in default). Must be paired with a `lineWidth` in the same
|
|
||||||
/// unit.
|
|
||||||
public typealias CharacterWidth = @Sendable (Character) -> CGFloat
|
|
||||||
|
|
||||||
// MARK: - Layout config
|
|
||||||
|
|
||||||
/// Describes how text wraps into visual lines. `lineWidth` and the values
|
|
||||||
/// returned by `widthOf` must share the same unit.
|
|
||||||
public struct VisualLineLayoutConfig: Sendable {
|
|
||||||
/// Wrap threshold: max total width of one visual line.
|
|
||||||
public let lineWidth: CGFloat
|
|
||||||
/// Per-character advance width provider.
|
|
||||||
public let widthOf: CharacterWidth
|
|
||||||
|
|
||||||
public init(
|
|
||||||
lineWidth: CGFloat,
|
|
||||||
widthOf: @escaping CharacterWidth = CursorNavigation.defaultDisplayWidth
|
|
||||||
) {
|
|
||||||
self.lineWidth = max(1, lineWidth)
|
|
||||||
self.widthOf = widthOf
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Conservative default when no field width is known.
|
|
||||||
public static let fallback = VisualLineLayoutConfig(lineWidth: 44)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
|
|
||||||
/// Legacy logical column (chars since last `\n`). Kept for tests.
|
|
||||||
public static func column(before: String?) -> Int {
|
|
||||||
guard let before, !before.isEmpty else { return 0 }
|
|
||||||
if let lastNewline = before.lastIndex(of: "\n") {
|
|
||||||
return before.distance(from: before.index(after: lastNewline), to: before.endIndex)
|
|
||||||
}
|
|
||||||
return before.count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Display-column offset (in `widthOf` units) on the current visual line.
|
|
||||||
public static func visualDisplayColumn(
|
|
||||||
before: String?,
|
|
||||||
after: String?,
|
|
||||||
config: VisualLineLayoutConfig
|
|
||||||
) -> CGFloat {
|
|
||||||
let text = mergedContext(before: before, after: after)
|
|
||||||
let cursor = before?.count ?? 0
|
|
||||||
let layout = VisualLineLayout(text: text, config: config)
|
|
||||||
let lineStart = layout.lineStart(containing: cursor)
|
|
||||||
return layout.width(from: lineStart, to: cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One visual line up. Returns caret offset and the display column to
|
|
||||||
/// keep sticky for the rest of this vertical drag.
|
|
||||||
public static func visualLineUpOffset(
|
|
||||||
before: String?,
|
|
||||||
after: String?,
|
|
||||||
preferredDisplayColumn: CGFloat?,
|
|
||||||
config: VisualLineLayoutConfig
|
|
||||||
) -> (offset: Int, stickyColumn: CGFloat)? {
|
|
||||||
let text = mergedContext(before: before, after: after)
|
|
||||||
let cursor = before?.count ?? 0
|
|
||||||
let layout = VisualLineLayout(text: text, config: config)
|
|
||||||
|
|
||||||
guard let currentLine = layout.lineIndex(containing: cursor), currentLine > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let sticky = preferredDisplayColumn
|
|
||||||
?? layout.width(from: layout.lineStarts[currentLine], to: cursor)
|
|
||||||
let previousStart = layout.lineStarts[currentLine - 1]
|
|
||||||
let previousEnd = layout.lineStarts[currentLine]
|
|
||||||
let target = layout.offset(
|
|
||||||
onLineStartingAt: previousStart,
|
|
||||||
lineEndingBefore: previousEnd,
|
|
||||||
displayColumn: sticky
|
|
||||||
)
|
|
||||||
let offset = target - cursor
|
|
||||||
guard offset != 0 else { return nil }
|
|
||||||
return (offset, sticky)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One visual line down.
|
|
||||||
public static func visualLineDownOffset(
|
|
||||||
before: String?,
|
|
||||||
after: String?,
|
|
||||||
preferredDisplayColumn: CGFloat?,
|
|
||||||
config: VisualLineLayoutConfig
|
|
||||||
) -> (offset: Int, stickyColumn: CGFloat)? {
|
|
||||||
let text = mergedContext(before: before, after: after)
|
|
||||||
let cursor = before?.count ?? 0
|
|
||||||
let layout = VisualLineLayout(text: text, config: config)
|
|
||||||
|
|
||||||
guard let currentLine = layout.lineIndex(containing: cursor) else { return nil }
|
|
||||||
guard currentLine + 1 < layout.lineStarts.count else { return nil }
|
|
||||||
|
|
||||||
let sticky = preferredDisplayColumn
|
|
||||||
?? layout.width(from: layout.lineStarts[currentLine], to: cursor)
|
|
||||||
let nextStart = layout.lineStarts[currentLine + 1]
|
|
||||||
let nextEnd = currentLine + 2 < layout.lineStarts.count
|
|
||||||
? layout.lineStarts[currentLine + 2]
|
|
||||||
: text.count
|
|
||||||
let target = layout.offset(
|
|
||||||
onLineStartingAt: nextStart,
|
|
||||||
lineEndingBefore: nextEnd,
|
|
||||||
displayColumn: sticky
|
|
||||||
)
|
|
||||||
let offset = target - cursor
|
|
||||||
guard offset != 0 else { return nil }
|
|
||||||
return (offset, sticky)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Default width table
|
|
||||||
|
|
||||||
/// Crude fallback advance width: wide scripts count double, everything
|
|
||||||
/// else single. Used by tests and when real metrics are unavailable.
|
|
||||||
public static func defaultDisplayWidth(_ character: Character) -> CGFloat {
|
|
||||||
guard let scalar = character.unicodeScalars.first else { return 1 }
|
|
||||||
if character == "\n" { return 0 }
|
|
||||||
if character == "\t" { return 4 }
|
|
||||||
if isWide(scalar) { return 2 }
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isWide(_ scalar: UnicodeScalar) -> Bool {
|
|
||||||
let value = scalar.value
|
|
||||||
return (0x1100...0x115F).contains(value) // Hangul Jamo
|
|
||||||
|| (0x2E80...0xA4CF).contains(value) // CJK radicals, symbols, bopomofo, yi
|
|
||||||
|| (0xAC00...0xD7A3).contains(value) // Hangul syllables
|
|
||||||
|| (0xF900...0xFAFF).contains(value) // CJK compatibility
|
|
||||||
|| (0xFE10...0xFE1F).contains(value) // vertical forms
|
|
||||||
|| (0xFE30...0xFE6F).contains(value) // CJK compatibility forms
|
|
||||||
|| (0xFF00...0xFF60).contains(value) // fullwidth
|
|
||||||
|| (0xFFE0...0xFFE6).contains(value) // fullwidth symbols
|
|
||||||
|| (0x20000...0x2FFFF).contains(value) // CJK extension planes
|
|
||||||
|| (0x30000...0x3FFFF).contains(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Internals
|
|
||||||
|
|
||||||
private static func mergedContext(before: String?, after: String?) -> String {
|
|
||||||
(before ?? "") + (after ?? "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Visual line layout
|
|
||||||
|
|
||||||
struct VisualLineLayout {
|
|
||||||
let text: String
|
|
||||||
let widthOf: CharacterWidth
|
|
||||||
let lineStarts: [Int]
|
|
||||||
|
|
||||||
init(text: String, config: VisualLineLayoutConfig) {
|
|
||||||
self.text = text
|
|
||||||
self.widthOf = config.widthOf
|
|
||||||
self.lineStarts = Self.computeLineStarts(
|
|
||||||
in: text,
|
|
||||||
maxWidth: config.lineWidth,
|
|
||||||
widthOf: config.widthOf
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func lineIndex(containing offset: Int) -> Int? {
|
|
||||||
guard !lineStarts.isEmpty else { return nil }
|
|
||||||
for index in lineStarts.indices.reversed() where offset >= lineStarts[index] {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func lineStart(containing offset: Int) -> Int {
|
|
||||||
lineIndex(containing: offset).map { lineStarts[$0] } ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func width(from start: Int, to end: Int) -> CGFloat {
|
|
||||||
guard start < end, end <= text.count else { return 0 }
|
|
||||||
let startIndex = text.index(text.startIndex, offsetBy: start)
|
|
||||||
let endIndex = text.index(text.startIndex, offsetBy: end)
|
|
||||||
var total: CGFloat = 0
|
|
||||||
var index = startIndex
|
|
||||||
while index < endIndex {
|
|
||||||
total += widthOf(text[index])
|
|
||||||
index = text.index(after: index)
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
func offset(
|
|
||||||
onLineStartingAt lineStart: Int,
|
|
||||||
lineEndingBefore lineEnd: Int,
|
|
||||||
displayColumn: CGFloat
|
|
||||||
) -> Int {
|
|
||||||
guard lineStart <= lineEnd, lineEnd <= text.count else { return lineStart }
|
|
||||||
let startIndex = text.index(text.startIndex, offsetBy: lineStart)
|
|
||||||
let endIndex = text.index(text.startIndex, offsetBy: lineEnd)
|
|
||||||
var total: CGFloat = 0
|
|
||||||
var index = startIndex
|
|
||||||
while index < endIndex {
|
|
||||||
let advance = widthOf(text[index])
|
|
||||||
if total + advance > displayColumn { break }
|
|
||||||
total += advance
|
|
||||||
index = text.index(after: index)
|
|
||||||
}
|
|
||||||
return text.distance(from: text.startIndex, to: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func computeLineStarts(
|
|
||||||
in text: String,
|
|
||||||
maxWidth: CGFloat,
|
|
||||||
widthOf: CharacterWidth
|
|
||||||
) -> [Int] {
|
|
||||||
guard !text.isEmpty else { return [0] }
|
|
||||||
|
|
||||||
var starts: [Int] = [0]
|
|
||||||
var lineWidth: CGFloat = 0
|
|
||||||
var lineStart = text.startIndex
|
|
||||||
var lastBreak: String.Index?
|
|
||||||
|
|
||||||
var index = text.startIndex
|
|
||||||
while index < text.endIndex {
|
|
||||||
let character = text[index]
|
|
||||||
|
|
||||||
if character == "\n" {
|
|
||||||
let next = text.index(after: index)
|
|
||||||
let nextOffset = text.distance(from: text.startIndex, to: next)
|
|
||||||
if starts.last != nextOffset {
|
|
||||||
starts.append(nextOffset)
|
|
||||||
}
|
|
||||||
lineStart = next
|
|
||||||
lineWidth = 0
|
|
||||||
lastBreak = nil
|
|
||||||
index = next
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let advance = widthOf(character)
|
|
||||||
if character == " " || character == "\t" {
|
|
||||||
lastBreak = index
|
|
||||||
}
|
|
||||||
|
|
||||||
if lineWidth + advance > maxWidth, index > lineStart {
|
|
||||||
let breakIndex: String.Index
|
|
||||||
if let lastBreak, lastBreak > lineStart {
|
|
||||||
breakIndex = text.index(after: lastBreak)
|
|
||||||
} else {
|
|
||||||
breakIndex = index
|
|
||||||
}
|
|
||||||
let breakOffset = text.distance(from: text.startIndex, to: breakIndex)
|
|
||||||
if starts.last != breakOffset {
|
|
||||||
starts.append(breakOffset)
|
|
||||||
}
|
|
||||||
lineStart = breakIndex
|
|
||||||
lineWidth = 0
|
|
||||||
lastBreak = nil
|
|
||||||
if breakIndex == index {
|
|
||||||
lineWidth = advance
|
|
||||||
index = text.index(after: index)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lineWidth += advance
|
|
||||||
index = text.index(after: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
return starts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
/// Real-font per-character advance widths (in points) for cursor visual-line
|
|
||||||
/// navigation. Caches measurements so repeated drag samples are cheap.
|
|
||||||
///
|
|
||||||
/// Absolute values assume a ~17 pt body font; only the *ratios* between
|
|
||||||
/// glyphs (and between a glyph and the field width) matter for column
|
|
||||||
/// fidelity, so a reference font is sufficient to eliminate the fixed-width
|
|
||||||
/// column drift.
|
|
||||||
///
|
|
||||||
/// Not actor-isolated on purpose: the width closure is invoked synchronously
|
|
||||||
/// from the nonisolated `CursorNavigation` layout code. A lock guards the
|
|
||||||
/// cache so `@unchecked Sendable` is safe.
|
|
||||||
public final class CursorGlyphMetrics: @unchecked Sendable {
|
|
||||||
public static let shared = CursorGlyphMetrics()
|
|
||||||
|
|
||||||
private let font = UIFont.systemFont(ofSize: 17)
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var cache: [Character: CGFloat] = [:]
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
public func width(of character: Character) -> CGFloat {
|
|
||||||
if character == "\n" { return 0 }
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
if let cached = cache[character] { return cached }
|
|
||||||
let measured = (String(character) as NSString)
|
|
||||||
.size(withAttributes: [.font: font])
|
|
||||||
.width
|
|
||||||
let width = measured > 0 ? measured : font.pointSize * 0.5
|
|
||||||
cache[character] = width
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -134,8 +134,6 @@ public final class KeyboardState: ObservableObject {
|
|||||||
/// Mirrors the host field's return-key intent. The action stays a newline
|
/// Mirrors the host field's return-key intent. The action stays a newline
|
||||||
/// insert; host apps decide whether that submits or creates a line break.
|
/// insert; host apps decide whether that submits or creates a line break.
|
||||||
@Published public var returnKeyRole: ReturnKeyRole = .newline
|
@Published public var returnKeyRole: ReturnKeyRole = .newline
|
||||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
|
||||||
@Published public var cursorDragNavigationEnabled: Bool = true
|
|
||||||
/// Opt-in clipboard history capture (mirrored from App Group).
|
/// Opt-in clipboard history capture (mirrored from App Group).
|
||||||
@Published public var clipboardHistoryEnabled: Bool = false
|
@Published public var clipboardHistoryEnabled: Bool = false
|
||||||
/// Opt-in clipboard suggestion strip (requires history enabled).
|
/// Opt-in clipboard suggestion strip (requires history enabled).
|
||||||
@@ -174,12 +172,11 @@ public final class KeyboardState: ObservableObject {
|
|||||||
/// constraint and the SwiftUI key grid pick their metrics from this one
|
/// constraint and the SwiftUI key grid pick their metrics from this one
|
||||||
/// value so they can never disagree and clip the bottom row.
|
/// value so they can never disagree and clip the bottom row.
|
||||||
@Published public var layoutWidth: CGFloat = 0
|
@Published public var layoutWidth: CGFloat = 0
|
||||||
/// `true` while a cursor-drag pad is being pressed — drives the hint
|
|
||||||
/// shown above the mic.
|
|
||||||
@Published public var cursorDragActive: Bool = false
|
|
||||||
/// `true` when the last voice insertion is still at the caret and can
|
/// `true` when the last voice insertion is still at the caret and can
|
||||||
/// be undone (suffix-checked against `documentContextBeforeInput`).
|
/// be undone (suffix-checked against `documentContextBeforeInput`).
|
||||||
@Published public var undoAvailable: Bool = false
|
@Published public var undoAvailable: Bool = false
|
||||||
|
/// A verified OSG-generated insertion can be edited by voice.
|
||||||
|
@Published public var editAvailable: Bool = false
|
||||||
/// `true` while an undone voice insertion can be re-applied (redo buffer).
|
/// `true` while an undone voice insertion can be re-applied (redo buffer).
|
||||||
@Published public var redoAvailable: Bool = false
|
@Published public var redoAvailable: Bool = false
|
||||||
/// `true` when the host field has a non-empty selection (copy enabled).
|
/// `true` when the host field has a non-empty selection (copy enabled).
|
||||||
@@ -190,6 +187,11 @@ public final class KeyboardState: ObservableObject {
|
|||||||
@Published public var editSession: EditSessionState = .inactive
|
@Published public var editSession: EditSessionState = .inactive
|
||||||
/// AI conversation UI state for the keyboard surface. The host owns the actual messages.
|
/// AI conversation UI state for the keyboard surface. The host owns the actual messages.
|
||||||
@Published public var aiSession: AISessionState = .inactive
|
@Published public var aiSession: AISessionState = .inactive
|
||||||
|
/// Whether the assistant's field action can invoke the host Return key.
|
||||||
|
/// Derived from the focused field's role and `UITextDocumentProxy.hasText`.
|
||||||
|
@Published public var assistantActionAvailable: Bool = false
|
||||||
|
/// Brief success pulse rendered on the unified assistant microphone.
|
||||||
|
@Published public var assistantInsertionSucceeded: Bool = false
|
||||||
@Published public var editCanReplaceOriginal: Bool = false
|
@Published public var editCanReplaceOriginal: Bool = false
|
||||||
/// Short idle feedback (availability, expiry, missing LLM).
|
/// Short idle feedback (availability, expiry, missing LLM).
|
||||||
@Published public var editHint: String?
|
@Published public var editHint: String?
|
||||||
@@ -254,6 +256,57 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var usesActionFill: Bool {
|
public var usesActionFill: Bool {
|
||||||
self != .newline
|
self != .newline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SF Symbol matching the semantic Return action exposed by the host.
|
||||||
|
public var assistantActionSystemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .newline:
|
||||||
|
return "arrow.turn.down.left"
|
||||||
|
case .send:
|
||||||
|
return "paperplane.fill"
|
||||||
|
case .search, .google, .yahoo:
|
||||||
|
return "magnifyingglass"
|
||||||
|
case .go, .route:
|
||||||
|
return "arrow.right.circle.fill"
|
||||||
|
case .join:
|
||||||
|
return "person.badge.plus"
|
||||||
|
case .done:
|
||||||
|
return "checkmark"
|
||||||
|
case .next, .continue:
|
||||||
|
return "arrow.right"
|
||||||
|
case .emergencyCall:
|
||||||
|
return "phone.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable suffix for UI automation and diagnostics.
|
||||||
|
public var assistantActionIdentifier: String {
|
||||||
|
switch self {
|
||||||
|
case .newline: return "newline"
|
||||||
|
case .send: return "send"
|
||||||
|
case .go: return "go"
|
||||||
|
case .search: return "search"
|
||||||
|
case .join: return "join"
|
||||||
|
case .done: return "done"
|
||||||
|
case .next: return "next"
|
||||||
|
case .continue: return "continue"
|
||||||
|
case .route: return "route"
|
||||||
|
case .google: return "google"
|
||||||
|
case .yahoo: return "yahoo"
|
||||||
|
case .emergencyCall: return "emergencyCall"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text-producing actions need content; navigation actions and newline
|
||||||
|
/// remain useful in an empty field.
|
||||||
|
public func assistantActionAvailable(hasText: Bool) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .send, .go, .search, .join, .route, .google, .yahoo:
|
||||||
|
return hasText
|
||||||
|
case .newline, .done, .next, .continue, .emergencyCall:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action hooks — injected by the view controller at install time.
|
// Action hooks — injected by the view controller at install time.
|
||||||
@@ -270,7 +323,11 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var closeEditMode: () -> Void = {}
|
public var closeEditMode: () -> Void = {}
|
||||||
public var tapAIMic: () -> Void = {}
|
public var tapAIMic: () -> Void = {}
|
||||||
public var cancelAIInput: () -> Void = {}
|
public var cancelAIInput: () -> Void = {}
|
||||||
public var sendAIAnswer: () -> Void = {}
|
/// Explicitly inserts a retained AI result after target validation failed.
|
||||||
|
public var confirmPendingAIAnswer: () -> Void = {}
|
||||||
|
public var discardPendingAIAnswer: () -> Void = {}
|
||||||
|
/// Performs the focused field's semantic Return action.
|
||||||
|
public var performAssistantFieldAction: () -> Void = {}
|
||||||
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
/// Sends a tapped idle hint card as the AI question (skip microphone).
|
||||||
public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
public var submitAIHint: (AIHintCard) -> Void = { _ in }
|
||||||
/// Sends a clipboard skill (reply / summarize / translate / export).
|
/// Sends a clipboard skill (reply / summarize / translate / export).
|
||||||
@@ -315,11 +372,6 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var copySelection: () -> Void = {}
|
public var copySelection: () -> Void = {}
|
||||||
/// Cut the current text selection (copy + delete).
|
/// Cut the current text selection (copy + delete).
|
||||||
public var cutSelection: () -> Void = {}
|
public var cutSelection: () -> Void = {}
|
||||||
public var moveCursorHorizontal: (Int) -> Void = { _ in }
|
|
||||||
public var moveCursorVertical: (Int) -> Void = { _ in }
|
|
||||||
/// Cursor-drag pad press lifecycle — updates `cursorDragActive` and
|
|
||||||
/// lets the view controller reset vertical-navigation stickiness.
|
|
||||||
public var setCursorDragActive: (Bool) -> Void = { _ in }
|
|
||||||
/// Switch voice ↔ typing. No-ops when voice pipeline is active.
|
/// Switch voice ↔ typing. No-ops when voice pipeline is active.
|
||||||
public var setSurface: (Surface) -> Void = { _ in }
|
public var setSurface: (Surface) -> Void = { _ in }
|
||||||
|
|
||||||
@@ -338,7 +390,7 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var canEnterTypingSurface: Bool { !locksTypingSurface }
|
public var canEnterTypingSurface: Bool { !locksTypingSurface }
|
||||||
|
|
||||||
public var canCancelAIInput: Bool {
|
public var canCancelAIInput: Bool {
|
||||||
surface == .ai && aiSession.isBusy
|
aiSession.isBusy
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normal dictation can be discarded from microphone startup through
|
/// Normal dictation can be discarded from microphone startup through
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ final class AIQuestionServiceTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(client.lastMessages?.last, .user(rawQuestion))
|
XCTAssertEqual(client.lastMessages?.last, .user(rawQuestion))
|
||||||
XCTAssertTrue(client.lastMessages?.first?.content.contains("Reply in English.") == true)
|
let systemPrompt = client.lastMessages?.first?.content ?? ""
|
||||||
|
XCTAssertTrue(systemPrompt.contains("Reply in English"))
|
||||||
|
XCTAssertTrue(systemPrompt.contains("explicitly asks for another output language"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAnswerDoesNotEnterContextUntilHostCommitsTerminalResult() async throws {
|
func testAnswerDoesNotEnterContextUntilHostCommitsTerminalResult() async throws {
|
||||||
|
|||||||
@@ -83,6 +83,22 @@ final class AISessionStateTests: XCTestCase {
|
|||||||
XCTAssertEqual(state.answer?.isSent, false)
|
XCTAssertEqual(state.answer?.isSent, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDiscardReadyAnswerKeepsConversationActive() {
|
||||||
|
var state = AISessionState()
|
||||||
|
let conversationID = UUID()
|
||||||
|
let utteranceID = UUID()
|
||||||
|
state.enter(conversationID: conversationID)
|
||||||
|
state.beginPreparing(utteranceID: utteranceID)
|
||||||
|
state.receiveAnswer("光标变化后保留的回答", utteranceID: utteranceID)
|
||||||
|
|
||||||
|
state.discardReadyAnswer()
|
||||||
|
|
||||||
|
XCTAssertEqual(state.phase, .idle)
|
||||||
|
XCTAssertEqual(state.conversationID, conversationID)
|
||||||
|
XCTAssertNil(state.answer)
|
||||||
|
XCTAssertFalse(state.canInsert)
|
||||||
|
}
|
||||||
|
|
||||||
func testCancellationRestoresPendingSendState() {
|
func testCancellationRestoresPendingSendState() {
|
||||||
var state = AISessionState()
|
var state = AISessionState()
|
||||||
let firstUtteranceID = UUID()
|
let firstUtteranceID = UUID()
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AssistantFieldActionTests: XCTestCase {
|
||||||
|
func testContentActionsRequireText() {
|
||||||
|
let roles: [KeyboardState.ReturnKeyRole] = [
|
||||||
|
.send,
|
||||||
|
.go,
|
||||||
|
.search,
|
||||||
|
.join,
|
||||||
|
.route,
|
||||||
|
.google,
|
||||||
|
.yahoo
|
||||||
|
]
|
||||||
|
|
||||||
|
for role in roles {
|
||||||
|
XCTAssertFalse(role.assistantActionAvailable(hasText: false))
|
||||||
|
XCTAssertTrue(role.assistantActionAvailable(hasText: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNavigationActionsRemainAvailableWithoutText() {
|
||||||
|
let roles: [KeyboardState.ReturnKeyRole] = [
|
||||||
|
.newline,
|
||||||
|
.done,
|
||||||
|
.next,
|
||||||
|
.continue,
|
||||||
|
.emergencyCall
|
||||||
|
]
|
||||||
|
|
||||||
|
for role in roles {
|
||||||
|
XCTAssertTrue(role.assistantActionAvailable(hasText: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchRolesUseSearchSymbol() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
KeyboardState.ReturnKeyRole.search.assistantActionSystemImage,
|
||||||
|
"magnifyingglass"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
KeyboardState.ReturnKeyRole.google.assistantActionSystemImage,
|
||||||
|
"magnifyingglass"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
KeyboardState.ReturnKeyRole.yahoo.assistantActionSystemImage,
|
||||||
|
"magnifyingglass"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
// CursorNavigationTests.swift
|
|
||||||
// OSGKeyboardTests
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import OSGKeyboardShared
|
|
||||||
|
|
||||||
final class CursorNavigationTests: XCTestCase {
|
|
||||||
|
|
||||||
/// Uses the built-in 1/2-unit width table. `lineWidth` is therefore in
|
|
||||||
/// "units" (≈ Latin characters) for these tests.
|
|
||||||
private func config(lineWidth: CGFloat) -> CursorNavigation.VisualLineLayoutConfig {
|
|
||||||
CursorNavigation.VisualLineLayoutConfig(lineWidth: lineWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testColumnOnFirstLine() {
|
|
||||||
XCTAssertEqual(CursorNavigation.column(before: "hello"), 5)
|
|
||||||
XCTAssertEqual(CursorNavigation.column(before: nil), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testColumnAfterNewline() {
|
|
||||||
XCTAssertEqual(CursorNavigation.column(before: "hello\nwor"), 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDefaultDisplayWidthLatinAndCJK() {
|
|
||||||
XCTAssertEqual(CursorNavigation.defaultDisplayWidth("a"), 1)
|
|
||||||
XCTAssertEqual(CursorNavigation.defaultDisplayWidth("中"), 2)
|
|
||||||
XCTAssertEqual(CursorNavigation.defaultDisplayWidth("\n"), 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testVisualLineDownAcrossSoftWrap() {
|
|
||||||
// 20 Latin chars, wrap at 16 → line0 [0,16), line1 [16,20).
|
|
||||||
let text = String(repeating: "a", count: 20)
|
|
||||||
let before = String(text.prefix(8))
|
|
||||||
let after = String(text.suffix(12))
|
|
||||||
|
|
||||||
let result = CursorNavigation.visualLineDownOffset(
|
|
||||||
before: before,
|
|
||||||
after: after,
|
|
||||||
preferredDisplayColumn: nil,
|
|
||||||
config: config(lineWidth: 16)
|
|
||||||
)
|
|
||||||
XCTAssertNotNil(result)
|
|
||||||
// Sticky column 8; line1 only has 4 units → clamp to its end.
|
|
||||||
XCTAssertEqual(result?.offset, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testVisualLineDownToShorterWrappedLineClampsToEnd() {
|
|
||||||
// Caret at col 15 (clearly on line0); line1 has only 4 chars.
|
|
||||||
let text = String(repeating: "a", count: 20)
|
|
||||||
let before = String(text.prefix(15))
|
|
||||||
let after = String(text.suffix(5))
|
|
||||||
|
|
||||||
let result = CursorNavigation.visualLineDownOffset(
|
|
||||||
before: before,
|
|
||||||
after: after,
|
|
||||||
preferredDisplayColumn: 15,
|
|
||||||
config: config(lineWidth: 16)
|
|
||||||
)
|
|
||||||
XCTAssertNotNil(result)
|
|
||||||
XCTAssertEqual(result?.offset, 5)
|
|
||||||
XCTAssertEqual(result?.stickyColumn, 15)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testVisualLineUpAcrossSoftWrap() {
|
|
||||||
let text = String(repeating: "a", count: 20)
|
|
||||||
let before = String(text.prefix(18))
|
|
||||||
let after = String(text.suffix(2))
|
|
||||||
|
|
||||||
let result = CursorNavigation.visualLineUpOffset(
|
|
||||||
before: before,
|
|
||||||
after: after,
|
|
||||||
preferredDisplayColumn: 8,
|
|
||||||
config: config(lineWidth: 16)
|
|
||||||
)
|
|
||||||
XCTAssertNotNil(result)
|
|
||||||
XCTAssertEqual(result?.offset, -10)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testVisualLineDownAcrossHardNewline() {
|
|
||||||
let before = "hello\nwor"
|
|
||||||
let after = "ld\nfoo"
|
|
||||||
|
|
||||||
let result = CursorNavigation.visualLineDownOffset(
|
|
||||||
before: before,
|
|
||||||
after: after,
|
|
||||||
preferredDisplayColumn: nil,
|
|
||||||
config: config(lineWidth: 100)
|
|
||||||
)
|
|
||||||
XCTAssertNotNil(result)
|
|
||||||
// "hello\nworld\nfoo" — caret before "ld"; column 3 lands after "foo".
|
|
||||||
XCTAssertEqual(result?.offset, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testVisualLineDownPreservesStickyColumnOnLongerNextLine() {
|
|
||||||
let before = "hello\nwor"
|
|
||||||
let after = "ld\nfoobarbaz"
|
|
||||||
|
|
||||||
let result = CursorNavigation.visualLineDownOffset(
|
|
||||||
before: before,
|
|
||||||
after: after,
|
|
||||||
preferredDisplayColumn: 5,
|
|
||||||
config: config(lineWidth: 100)
|
|
||||||
)
|
|
||||||
XCTAssertNotNil(result)
|
|
||||||
// "ld\n" (3) + column 5 on "foobarbaz" = 8 total from cursor.
|
|
||||||
XCTAssertEqual(result?.offset, 8)
|
|
||||||
XCTAssertEqual(result?.stickyColumn, 5)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testVisualLineUpOnFirstLineReturnsNil() {
|
|
||||||
XCTAssertNil(
|
|
||||||
CursorNavigation.visualLineUpOffset(
|
|
||||||
before: "hello",
|
|
||||||
after: " world",
|
|
||||||
preferredDisplayColumn: nil,
|
|
||||||
config: config(lineWidth: 100)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testVisualLineDownWithNoFollowingTextReturnsNil() {
|
|
||||||
XCTAssertNil(
|
|
||||||
CursorNavigation.visualLineDownOffset(
|
|
||||||
before: "hello",
|
|
||||||
after: nil,
|
|
||||||
preferredDisplayColumn: nil,
|
|
||||||
config: config(lineWidth: 100)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
XCTAssertNil(
|
|
||||||
CursorNavigation.visualLineDownOffset(
|
|
||||||
before: "hello",
|
|
||||||
after: "",
|
|
||||||
preferredDisplayColumn: nil,
|
|
||||||
config: config(lineWidth: 100)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,6 +42,31 @@ final class EditableInputReferenceTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testLiveInsertionSurvivesDelayedFieldFingerprintRefresh() {
|
||||||
|
let instanceID = UUID()
|
||||||
|
let reference = EditableInputReference(
|
||||||
|
displayText: "hello",
|
||||||
|
insertedText: " hello",
|
||||||
|
postInsertionFingerprint: "fingerprint-before-refresh",
|
||||||
|
extensionInstanceID: instanceID
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
reference.matchesLiveInsertion(
|
||||||
|
extensionInstanceID: instanceID,
|
||||||
|
lastInsertedText: " hello",
|
||||||
|
contextBeforeInput: "prefix hello"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
reference.matchesLiveInsertion(
|
||||||
|
extensionInstanceID: UUID(),
|
||||||
|
lastInsertedText: " hello",
|
||||||
|
contextBeforeInput: "prefix hello"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func testStoreRoundTripAndExpiryCleanup() throws {
|
func testStoreRoundTripAndExpiryCleanup() throws {
|
||||||
let suite = "EditableInputReferenceTests.\(UUID().uuidString)"
|
let suite = "EditableInputReferenceTests.\(UUID().uuidString)"
|
||||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AssistantKeyboardUITests: XCTestCase {
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
continueAfterFailure = false
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTapRoutesToOrdinaryDictation() {
|
||||||
|
let app = launch(scenario: "idle")
|
||||||
|
let idleMic = element("assistant.mic.idle", in: app)
|
||||||
|
XCTAssertTrue(idleMic.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
idleMic.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
element("assistant.mic.dictationRecording", in: app)
|
||||||
|
.waitForExistence(timeout: 3)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(element("assistant.action.send", in: app).isHittable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIdleHintHasBalancedSpacingAndFullCapsuleHitTarget() {
|
||||||
|
let app = launch(scenario: "idle")
|
||||||
|
let tab = requiredElement("assistant.tab.assistant", in: app)
|
||||||
|
let hint = requiredElement("assistant.hint", in: app)
|
||||||
|
let mic = requiredElement("assistant.mic.idle", in: app)
|
||||||
|
|
||||||
|
let topGap = hint.frame.minY - tab.frame.maxY
|
||||||
|
let bottomGap = mic.frame.minY - hint.frame.maxY
|
||||||
|
XCTAssertEqual(topGap, bottomGap, accuracy: 2)
|
||||||
|
XCTAssertTrue(hint.isHittable)
|
||||||
|
|
||||||
|
hint.coordinate(withNormalizedOffset: CGVector(dx: 0.08, dy: 0.5)).tap()
|
||||||
|
let waitingMic = element("assistant.mic.aiGenerating", in: app)
|
||||||
|
XCTAssertTrue(waitingMic.waitForExistence(timeout: 3))
|
||||||
|
XCTAssertFalse(waitingMic.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTranslationPrecedesClipboardOnTrailingEdge() {
|
||||||
|
let app = launch(scenario: "idle")
|
||||||
|
let translation = requiredElement("assistant.translation", in: app)
|
||||||
|
let clipboard = requiredElement("assistant.clipboard", in: app)
|
||||||
|
|
||||||
|
XCTAssertLessThan(translation.frame.midX, clipboard.frame.midX)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSkillFailureCapsuleFollowsTextWidth() {
|
||||||
|
let app = launch(scenario: "skillFailure")
|
||||||
|
let tip = requiredElement("assistant.skillTip", in: app)
|
||||||
|
|
||||||
|
XCTAssertLessThan(tip.frame.width, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHoldRoutesToAIListeningWithoutAlsoStartingDictation() {
|
||||||
|
let app = launch(scenario: "idle")
|
||||||
|
let idleMic = element("assistant.mic.idle", in: app)
|
||||||
|
XCTAssertTrue(idleMic.waitForExistence(timeout: 10))
|
||||||
|
|
||||||
|
idleMic.press(forDuration: 0.7)
|
||||||
|
|
||||||
|
let listeningMic = element("assistant.mic.aiListening", in: app)
|
||||||
|
XCTAssertTrue(listeningMic.waitForExistence(timeout: 3))
|
||||||
|
XCTAssertFalse(element("assistant.mic.dictationRecording", in: app).exists)
|
||||||
|
|
||||||
|
listeningMic.tap()
|
||||||
|
|
||||||
|
let waitingMic = element("assistant.mic.aiRecognizing", in: app)
|
||||||
|
XCTAssertTrue(waitingMic.waitForExistence(timeout: 3))
|
||||||
|
XCTAssertFalse(waitingMic.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCompletedInputRevealsUndoEditAndSendWithoutOverlap() {
|
||||||
|
let app = launch(scenario: "completed")
|
||||||
|
let mic = requiredElement("assistant.mic.idle", in: app)
|
||||||
|
let delete = requiredElement("assistant.delete", in: app)
|
||||||
|
let space = requiredElement("assistant.space", in: app)
|
||||||
|
let undo = requiredElement("assistant.undo", in: app)
|
||||||
|
let edit = requiredElement("assistant.edit", in: app)
|
||||||
|
let send = requiredElement("assistant.action.send", in: app)
|
||||||
|
|
||||||
|
XCTAssertTrue(send.isEnabled)
|
||||||
|
assertNoIntersection(delete, mic)
|
||||||
|
assertNoIntersection(space, mic)
|
||||||
|
assertNoIntersection(undo, send)
|
||||||
|
assertNoIntersection(edit, send)
|
||||||
|
XCTAssertGreaterThan(send.frame.minY, mic.frame.maxY)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEditButtonRunsStopAndConfirmCycle() {
|
||||||
|
let app = launch(scenario: "completed")
|
||||||
|
|
||||||
|
requiredElement("assistant.edit", in: app).tap()
|
||||||
|
let stop = requiredElement("assistant.edit.stop", in: app)
|
||||||
|
XCTAssertTrue(stop.isHittable)
|
||||||
|
|
||||||
|
stop.tap()
|
||||||
|
let confirm = requiredElement("assistant.edit.confirm", in: app)
|
||||||
|
XCTAssertTrue(confirm.isHittable)
|
||||||
|
|
||||||
|
confirm.tap()
|
||||||
|
XCTAssertTrue(
|
||||||
|
element("assistant.mic.idle", in: app)
|
||||||
|
.waitForExistence(timeout: 3)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChangedTargetAnswerCanBeInsertedExplicitly() {
|
||||||
|
let app = launch(scenario: "pending")
|
||||||
|
let insert = requiredElement("assistant.pending.insert", in: app)
|
||||||
|
XCTAssertTrue(element("assistant.pending.discard", in: app).exists)
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts["A retained answer that requires explicit insertion."].exists
|
||||||
|
)
|
||||||
|
|
||||||
|
insert.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
element("assistant.mic.idle", in: app)
|
||||||
|
.waitForExistence(timeout: 3)
|
||||||
|
)
|
||||||
|
XCTAssertTrue(requiredElement("assistant.action.send", in: app).isEnabled)
|
||||||
|
XCTAssertTrue(element("assistant.undo", in: app).exists)
|
||||||
|
XCTAssertTrue(element("assistant.edit", in: app).exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClipboardSkillsPageHorizontallyAndDismissTogether() {
|
||||||
|
let app = launch(scenario: "skills")
|
||||||
|
let pager = requiredElement("assistant.skills.pager", in: app)
|
||||||
|
let navigate = element("assistant.skill.navigate", in: app)
|
||||||
|
let sideActions = [
|
||||||
|
requiredElement("assistant.delete", in: app),
|
||||||
|
requiredElement("assistant.space", in: app),
|
||||||
|
requiredElement("assistant.undo", in: app),
|
||||||
|
requiredElement("assistant.edit", in: app)
|
||||||
|
]
|
||||||
|
|
||||||
|
for action in sideActions {
|
||||||
|
XCTAssertTrue(action.isHittable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pager.swipeLeft()
|
||||||
|
|
||||||
|
XCTAssertTrue(navigate.waitForExistence(timeout: 3))
|
||||||
|
XCTAssertTrue(navigate.isHittable)
|
||||||
|
|
||||||
|
requiredElement("assistant.clipboard.dismiss", in: app).tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
element("assistant.mic.idle", in: app)
|
||||||
|
.waitForExistence(timeout: 3)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(element("assistant.skills.pager", in: app).exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchFieldShowsEnabledSearchAction() {
|
||||||
|
let app = launch(scenario: "search")
|
||||||
|
let search = requiredElement("assistant.action.search", in: app)
|
||||||
|
|
||||||
|
XCTAssertTrue(search.isEnabled)
|
||||||
|
XCTAssertTrue(search.isHittable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testActionGeometrySurvivesLandscapeRotation() {
|
||||||
|
let app = launch(scenario: "completed")
|
||||||
|
_ = requiredElement("assistant.mic.idle", in: app)
|
||||||
|
|
||||||
|
XCUIDevice.shared.orientation = .landscapeLeft
|
||||||
|
|
||||||
|
let mic = requiredElement("assistant.mic.idle", in: app)
|
||||||
|
let delete = requiredElement("assistant.delete", in: app)
|
||||||
|
let space = requiredElement("assistant.space", in: app)
|
||||||
|
let undo = requiredElement("assistant.undo", in: app)
|
||||||
|
let edit = requiredElement("assistant.edit", in: app)
|
||||||
|
let send = requiredElement("assistant.action.send", in: app)
|
||||||
|
assertNoIntersection(delete, mic)
|
||||||
|
assertNoIntersection(space, mic)
|
||||||
|
assertNoIntersection(undo, send)
|
||||||
|
assertNoIntersection(edit, send)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launch(scenario: String) -> XCUIApplication {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = [
|
||||||
|
"--assistant-ui-test",
|
||||||
|
"--assistant-state=\(scenario)",
|
||||||
|
]
|
||||||
|
app.launch()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ identifier: String, in app: XCUIApplication) -> XCUIElement {
|
||||||
|
app.descendants(matching: .any)[identifier]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requiredElement(
|
||||||
|
_ identifier: String,
|
||||||
|
in app: XCUIApplication
|
||||||
|
) -> XCUIElement {
|
||||||
|
let match = element(identifier, in: app)
|
||||||
|
XCTAssertTrue(match.waitForExistence(timeout: 10), "Missing \(identifier)")
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertNoIntersection(
|
||||||
|
_ first: XCUIElement,
|
||||||
|
_ second: XCUIElement,
|
||||||
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) {
|
||||||
|
XCTAssertFalse(
|
||||||
|
first.frame.intersects(second.frame),
|
||||||
|
"\(first.identifier) overlaps \(second.identifier)",
|
||||||
|
file: file,
|
||||||
|
line: line
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
// EnglishKeyboardDeviceUITests.swift
|
// EnglishKeyboardDeviceUITests.swift
|
||||||
// OSGKeyboardUITests
|
// OSGKeyboardUITests
|
||||||
//
|
//
|
||||||
// Physical-device pass: Notes-like host + the real keyboard extension.
|
// Notes-like host + the real keyboard extension on device or simulator.
|
||||||
// Skips if OSGKeyboard is not enabled as the current keyboard.
|
// Skips only when OSGKeyboard is not enabled as the current keyboard.
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class EnglishKeyboardDeviceUITests: XCTestCase {
|
final class EnglishKeyboardDeviceUITests: XCTestCase {
|
||||||
func testOSGKeyboardAppearsOnNotesHost() throws {
|
func testOSGKeyboardAppearsOnNotesHost() throws {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
@@ -26,9 +27,16 @@ final class EnglishKeyboardDeviceUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
textView.tap()
|
textView.tap()
|
||||||
|
|
||||||
|
let surfaceInHost = app.descendants(matching: .any)["assistant.surface"]
|
||||||
|
if surfaceInHost.waitForExistence(timeout: 8) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let keyboard = XCUIApplication(bundleIdentifier: "com.osgkeyboard.ios.keyboard")
|
let keyboard = XCUIApplication(bundleIdentifier: "com.osgkeyboard.ios.keyboard")
|
||||||
|
let surfaceInExtension = keyboard.descendants(matching: .any)["assistant.surface"]
|
||||||
let appeared = keyboard.wait(for: .runningForeground, timeout: 8)
|
let appeared = keyboard.wait(for: .runningForeground, timeout: 8)
|
||||||
|| keyboard.windows.firstMatch.waitForExistence(timeout: 8)
|
|| keyboard.windows.firstMatch.waitForExistence(timeout: 8)
|
||||||
|
|| surfaceInExtension.waitForExistence(timeout: 8)
|
||||||
if !appeared {
|
if !appeared {
|
||||||
throw XCTSkip(
|
throw XCTSkip(
|
||||||
"OSGKeyboard extension is not the active keyboard on this device. Enable it in Settings ▸ Keyboard, then re-run."
|
"OSGKeyboard extension is not the active keyboard on this device. Enable it in Settings ▸ Keyboard, then re-run."
|
||||||
@@ -36,7 +44,7 @@ final class EnglishKeyboardDeviceUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
keyboard.windows.firstMatch.exists,
|
keyboard.windows.firstMatch.exists || surfaceInExtension.exists,
|
||||||
"OSGKeyboard extension window should be on screen"
|
"OSGKeyboard extension window should be on screen"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# App Store Connect — OSGKeyboard 1.8.0 (build 72)
|
# App Store Connect — OSGKeyboard 1.8.0 (build 74)
|
||||||
|
|
||||||
> Current metadata baseline for the iOS/iPadOS App Store build. Version and build
|
> Current metadata baseline for the iOS/iPadOS App Store build. Version and build
|
||||||
> numbers come from `project.yml`. The repository also contains a separate
|
> numbers come from `project.yml`. The repository also contains a separate
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
| App name | `OSGKeyboard` | ≤ 30 characters |
|
| App name | `OSGKeyboard` | ≤ 30 characters |
|
||||||
| Subtitle | `Voice input, everywhere` | ≤ 30 characters |
|
| Subtitle | `Voice input, everywhere` | ≤ 30 characters |
|
||||||
| Bundle ID | `com.osgkeyboard.ios` | iOS host target |
|
| Bundle ID | `com.osgkeyboard.ios` | iOS host target |
|
||||||
| Version / build | `1.8.0` / `72` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` |
|
| Version / build | `1.8.0` / `74` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` |
|
||||||
| Minimum system | iOS/iPadOS 26 | iPhone and iPad |
|
| Minimum system | iOS/iPadOS 26 | iPhone and iPad |
|
||||||
| Primary locale | `en-US` | Simplified Chinese is also bundled |
|
| Primary locale | `en-US` | Simplified Chinese is also bundled |
|
||||||
| Primary category | Utilities | |
|
| Primary category | Utilities | |
|
||||||
@@ -211,10 +211,10 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added.
|
|||||||
|
|
||||||
## Submission checklist
|
## Submission checklist
|
||||||
|
|
||||||
- [ ] Confirm `project.yml` still reads version 1.8.0 / build 72
|
- [ ] Confirm `project.yml` still reads version 1.8.0 / build 74
|
||||||
- [ ] Open the existing Xcode project (do not regenerate unless needed)
|
- [ ] Open the existing Xcode project (do not regenerate unless needed)
|
||||||
- [ ] Run the release build and test suites on macOS with Xcode 26
|
- [ ] Run the release build and test suites on macOS with Xcode 26
|
||||||
- [ ] Replace screenshots with captures from the submitted build
|
- [ ] Replace screenshots with captures from the submitted build
|
||||||
- [ ] Verify the privacy answers against the submitted provider features
|
- [ ] Verify the privacy answers against the submitted provider features
|
||||||
- [ ] Confirm the tip product remains optional and unlocks no feature
|
- [ ] Confirm the tip product remains optional and unlocks no feature
|
||||||
- [ ] Upload, select build 72, add review notes, and submit
|
- [ ] Upload, select build 74, add review notes, and submit
|
||||||
|
|||||||
+5
-2
@@ -49,7 +49,7 @@ settings:
|
|||||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||||
MARKETING_VERSION: "1.8.0"
|
MARKETING_VERSION: "1.8.0"
|
||||||
CURRENT_PROJECT_VERSION: "72"
|
CURRENT_PROJECT_VERSION: "75"
|
||||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||||
|
|
||||||
# 项目级签名 xcconfig,适用于所有 target
|
# 项目级签名 xcconfig,适用于所有 target
|
||||||
@@ -83,7 +83,11 @@ targets:
|
|||||||
- path: OSGKeyboardExt/Views/LastInputEditView.swift
|
- path: OSGKeyboardExt/Views/LastInputEditView.swift
|
||||||
- path: OSGKeyboardExt/Views/KeyboardTopControls.swift
|
- path: OSGKeyboardExt/Views/KeyboardTopControls.swift
|
||||||
- path: OSGKeyboardExt/Views/ClipboardKeyboardViews.swift
|
- path: OSGKeyboardExt/Views/ClipboardKeyboardViews.swift
|
||||||
|
- path: OSGKeyboardExt/Views/ToolbarActionButtons.swift
|
||||||
|
- path: OSGKeyboardExt/Views/GlobeInputModeButton.swift
|
||||||
- path: OSGKeyboardExt/Views/NativeKeyboardKeyStyle.swift
|
- path: OSGKeyboardExt/Views/NativeKeyboardKeyStyle.swift
|
||||||
|
- path: OSGKeyboardExt/Utilities/KeyboardHapticFeedback.swift
|
||||||
|
- path: OSGKeyboardExt/Utilities/KeyboardSoundFeedback.swift
|
||||||
- path: OSGKeyboardExt/en.lproj/Keyboard.strings
|
- path: OSGKeyboardExt/en.lproj/Keyboard.strings
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
|
- path: OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
|
||||||
@@ -550,7 +554,6 @@ targets:
|
|||||||
- "DesignSystem/RecordButtonGesturePolicy.swift"
|
- "DesignSystem/RecordButtonGesturePolicy.swift"
|
||||||
- "Services/KeyboardState.swift"
|
- "Services/KeyboardState.swift"
|
||||||
- "Services/KeyboardOpenSurfacePolicy.swift"
|
- "Services/KeyboardOpenSurfacePolicy.swift"
|
||||||
- "Services/CursorNavigation.swift"
|
|
||||||
- "Models/MicVoiceAvailability+Keyboard.swift"
|
- "Models/MicVoiceAvailability+Keyboard.swift"
|
||||||
- "Models/TypingInputConfiguration.swift"
|
- "Models/TypingInputConfiguration.swift"
|
||||||
- "Models/TypingSurfaceMetrics.swift"
|
- "Models/TypingSurfaceMetrics.swift"
|
||||||
|
|||||||
Reference in New Issue
Block a user