perf(keyboard): track KeyboardState per property with @Observable

- KeyboardState moves from ObservableObject/@Published to @Observable, so a view re-renders only for the properties it reads. Previously unpublished state and injected action hooks are @ObservationIgnored.
- KeyboardViewController replaces the $phase/$surface Combine sinks with Observations loops tied to the same cancellables.
- AIKeyboardView reads the 200 ms mic level and live ASR partials in small child views (AssistantWaveform, LiveTranscriptReader), so those ticks no longer rebuild the whole assistant surface.
- Tests observe changes with withObservationTracking instead of Combine publishers.
This commit is contained in:
2026-10-21 06:12:21 +02:00
parent 6e64f96dce
commit cfe14f67d4
17 changed files with 220 additions and 173 deletions
+1
View File
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Messaging shift to AI voice keyboard / voice double / agentic skills**: README and the product page now lead with “Say it. It’s typed. Speaks like you. Acts for you.” — the personalization story (learned style, three-tone replies) moves ahead of typing, clipboard skills are framed as agentic actions, and typing becomes the fallback capability. / **宣传定位转向 AI 语音键盘 / 嘴替 / Agentic 技能**:README 与产品页改用「开口即文字。会说你的话,会办你的事。」定位;个人化卖点(专属风格、三语气回复)提前,剪贴板技能表述升级为 Agentic 行动,打字退为兜底能力。
- **Product page redesign**: rebuild the GitHub Pages home as a dark product stage with the new Icon Composer app icon, the three-device hero visual, and real on-device videos and UI cards replacing hand-drawn mockups; add 2.1 selling points (personal style, three-tone replies, monthly usage calendar, EventKit skills export, guided trials and credit rewards), a TestFlight link, and self-hosted inline SVG icons; bump page references to 2.1.0. / **产品页重设计**:GitHub Pages 主页改为深色产品舞台,启用 Icon Composer 新图标与三端同框主视觉,以实机视频与真实界面卡片取代手绘示意;补齐 2.1 卖点(专属风格、三语气个性回复、月度用量日历、EventKit 技能写入、引导体验与积分奖励),新增 TestFlight 入口,图标改为自托管内联 SVG;页面版本号更新至 2.1.0。
- **Host-only analytics recording**: the keyboard extension no longer opens the analytics or keyboard-usage SQLite queues. The host app is the only process that records and uploads events, so hiding the keyboard cannot hold WAL locks and get killed as `0xdead10cc`. Keyboard-activated and per-keystroke usage events are no longer collected. / **仅主 App 记埋点**:键盘扩展不再打开统计与键盘用量 SQLite 队列;开库、记账与上传只在主 App 进行,收起键盘时不会因 WAL 锁被系统以 `0xdead10cc` 终止。不再采集「键盘激活」和逐次按键用量。
- **Lighter keyboard redraws while dictating**: the keyboard now redraws only the parts whose data changed. The microphone level (5 times a second) updates just the waveform bars, and live recognized text updates just the status line, instead of rebuilding the whole voice panel each time. / **听写时键盘重绘更轻**:键盘现在只重绘数据有变化的部分。麦克风音量(每秒 5 次)只刷新波形条,实时识别文字只刷新状态行,不再每次都重建整个语音面板。
### Fixed
- **Keyboard killed after hiding on pinyin**: hiding the keyboard while the pinyin/English typing surface was showing kept librime's user dictionary open, and its LevelDB `LOCK` file in the shared App Group got the suspended extension killed as `0xdead10cc` (about 30 kills in 3 days on one test device). The typing engine is now closed on every hide and reopened on the next show, which adds about 30–50 ms only when a still-running keyboard is reopened. / **拼音界面收起后键盘被系统终止**:在拼音/英文打字界面收起键盘时,librime 用户词库仍保持打开,其位于共享 App Group 的 LevelDB `LOCK` 文件导致挂起的扩展被系统以 `0xdead10cc` 终止(一台测试机 3 天约 30 次)。现在每次收起都会关闭打字引擎、下次弹出时重新打开,仅在复用仍存活的键盘进程时多约 30–50 毫秒。
@@ -12,7 +12,7 @@ import SwiftUI
struct AIClipboardSkillLayoutDemoView: View {
@StateObject private var config = ProviderConfig.shared
@StateObject private var state = KeyboardState()
@State private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@State private var count: Int = Self.initialCount
+1 -1
View File
@@ -17,7 +17,7 @@ struct AIKeyboardDemoView: View {
}
@StateObject private var config = ProviderConfig.shared
@StateObject private var state = KeyboardState()
@State private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@State private var scene: Scene = .settings
@@ -20,7 +20,7 @@ struct AssistantKeyboardUITestHarness: View {
case yesNoReply
}
@StateObject private var state = KeyboardState()
@State private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@State private var configured = false
@Environment(\.colorScheme) private var colorScheme
@@ -21,7 +21,7 @@ struct ClipboardHistoryDemoView: View {
(actionClusterTopGap - micToButtonGap) / 2
}
@StateObject private var state = KeyboardState()
@State private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
// `AIKeyboardView` ranks its skill row off `ClipboardHistoryStore.shared`
// and `ClipboardSemanticRankingStore.shared`. Driving a private store here
+1 -1
View File
@@ -31,7 +31,7 @@ struct EditDemoView: View {
case editing
}
@StateObject private var state = KeyboardState()
@State private var state = KeyboardState()
@State private var scene: Scene = .hint
/// Mirrors the host document for `--preview-fullscreen` recordings.
@State private var hostText: String = EditDemoView.originalText
+1 -1
View File
@@ -14,7 +14,7 @@ import SwiftUI
struct SmartReplyDemoView: View {
@StateObject private var config = ProviderConfig.shared
@StateObject private var state = KeyboardState()
@State private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
var body: some View {
+29 -19
View File
@@ -778,28 +778,38 @@ public final class KeyboardViewController: UIInputViewController {
}
private func installSurfaceObservers() {
state.$phase
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
if self.state.locksTypingSurface, self.state.surface == .typing {
self.applySurface(.voice)
}
let state = state
// `Phase` is not Sendable; reading it only registers the dependency.
observe(Observations { _ = state.phase }) { [weak self] in
guard let self else { return }
if self.state.locksTypingSurface, self.state.surface == .typing {
self.applySurface(.voice)
}
.store(in: &cancellables)
}
state.$surface
.receive(on: RunLoop.main)
.sink { [weak self] surface in
guard let self else { return }
if surface == .typing {
self.activateTypingPresentationIfNeeded()
} else {
self.deactivateTypingSurface()
}
self.refreshKeyboardHeight()
observe(Observations { state.surface }) { [weak self] surface in
guard let self else { return }
if surface == .typing {
self.activateTypingPresentationIfNeeded()
} else {
self.deactivateTypingSurface()
}
.store(in: &cancellables)
self.refreshKeyboardHeight()
}
}
/// Runs `handler` for the current value and every later change. The loop
/// is cancelled together with `cancellables` when the controller goes away.
private func observe<Element: Sendable>(
_ values: Observations<Element, Never>,
handler: @escaping @MainActor (Element) -> Void
) {
let task = Task { @MainActor in
for await value in values {
handler(value)
}
}
cancellables.insert(AnyCancellable { task.cancel() })
}
/// This App Group property is supplied by the parallel settings module.
@@ -10,7 +10,7 @@ struct KeyboardSurfaceRoot: View {
@Environment(\.colorScheme) private var colorScheme
@Namespace private var keyboardTabSelectionNamespace
@ObservedObject var state: KeyboardState
var state: KeyboardState
@ObservedObject var typing: TypingSessionController
var onInsert: (String) -> Void
+1 -1
View File
@@ -38,7 +38,7 @@ enum TypingLayoutMetrics {
struct TypingRootView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: KeyboardState
var state: KeyboardState
@ObservedObject var typing: TypingSessionController
var onInsert: (String) -> Void
+52 -19
View File
@@ -64,7 +64,7 @@ struct AIKeyboardView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
var state: KeyboardState
@ObservedObject var typing: TypingSessionController
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
@ObservedObject private var semanticRanking = ClipboardSemanticRankingStore.shared
@@ -604,9 +604,9 @@ struct AIKeyboardView: View {
@ViewBuilder
private var contextArea: some View {
ZStack {
if showsOOBEClipboardSkill, activeStatus == nil {
if showsOOBEClipboardSkill, !hasActiveStatus {
clipboardSkillPager
} else if let onboardingPracticeHint, activeStatus == nil {
} else if let onboardingPracticeHint, !hasActiveStatus {
Text(onboardingPracticeHint)
.font(TypeStyle.footnote.weight(.semibold))
.foregroundStyle(palette.textPrimary)
@@ -636,8 +636,12 @@ struct AIKeyboardView: View {
}
} else if let content = semanticBadgeContent {
semanticBadge(content)
} else if let status = activeStatus {
statusText(status.text, color: status.color)
} else if hasActiveStatus {
LiveTranscriptReader(state: state) { transcript in
if let status = activeStatus(lastTranscript: transcript) {
statusText(status.text, color: status.color)
}
}
} else if showsLongPressCoach {
Text(ExtL10n.string("keyboard.assistant.longPressCoach"))
.font(TypeStyle.footnote.weight(.medium))
@@ -720,7 +724,14 @@ struct AIKeyboardView: View {
.frame(maxWidth: .infinity)
}
private var activeStatus: (text: String, color: Color)? {
/// Whether a status line replaces the idle content. Never depends on the
/// transcript text, so this surface does not re-render per ASR partial.
private var hasActiveStatus: Bool {
activeStatus(lastTranscript: "") != nil
}
/// `lastTranscript` is passed in so only `LiveTranscriptReader` tracks it.
private func activeStatus(lastTranscript: String) -> (text: String, color: Color)? {
if let error = state.aiSession.errorMessage,
!error.isEmpty,
!ordinaryPipelineIsActive {
@@ -757,18 +768,18 @@ struct AIKeyboardView: View {
return nil
case .requestingPermissions:
return (
state.lastTranscript.isEmpty
lastTranscript.isEmpty
? ExtL10n.string("keyboard.placeholder.preparing")
: state.lastTranscript,
: lastTranscript,
palette.textSecondary
)
case .recording:
return (state.lastTranscript.isEmpty ? " " : state.lastTranscript, palette.textPrimary)
return (lastTranscript.isEmpty ? " " : lastTranscript, palette.textPrimary)
case .processing:
return (
state.lastTranscript.isEmpty
lastTranscript.isEmpty
? ExtL10n.string("keyboard.placeholder.processing")
: state.lastTranscript,
: lastTranscript,
palette.textSecondary
)
case .error(_, let message):
@@ -1149,14 +1160,7 @@ struct AIKeyboardView: View {
}
private func waveform(color: Color) -> some View {
WaveformView(
level: state.level,
barCount: 7,
color: color,
active: true
)
.frame(width: 38, height: 26)
.clipped()
AssistantWaveform(state: state, color: color)
}
private var microphoneTint: Color {
@@ -1562,6 +1566,35 @@ struct AIKeyboardView: View {
/// Uses a subview's intrinsic width until it reaches a visual cap. Unlike
/// `frame(maxWidth:)`, this layout does not expand short labels to the cap,
/// so each capsule and its complete hit target follow the actual content.
/// Reads `state.level` in its own body so the 200 ms mic level tick re-renders
/// only these bars instead of the whole assistant surface.
private struct AssistantWaveform: View {
let state: KeyboardState
let color: Color
var body: some View {
WaveformView(
level: state.level,
barCount: 7,
color: color,
active: true
)
.frame(width: 38, height: 26)
.clipped()
}
}
/// Reads `state.lastTranscript` in its own body so per-word ASR partials
/// re-render only the status line.
private struct LiveTranscriptReader<Content: View>: View {
let state: KeyboardState
@ViewBuilder let content: (String) -> Content
var body: some View {
content(state.lastTranscript)
}
}
private struct IntrinsicWidthCap: Layout {
let maxWidth: CGFloat
@@ -12,7 +12,7 @@ import SwiftUI
import UIKit
struct GlobeInputModeButton: UIViewRepresentable {
@ObservedObject var state: KeyboardState
var state: KeyboardState
@Binding var isPressed: Bool
func makeUIView(context: Context) -> GlobeInputModeButtonView {
@@ -41,7 +41,7 @@ struct GlobeInputModeButton: UIViewRepresentable {
struct SystemGlobeKey: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
@ObservedObject var state: KeyboardState
var state: KeyboardState
@State private var isPressed = false
var width: CGFloat = 44
@@ -112,7 +112,7 @@ struct KeyboardTopControls: View {
@Environment(\.keyboardTabSelectionNamespace) private var sharedSelectionNamespace
@Namespace private var fallbackSelectionNamespace
@ObservedObject var state: KeyboardState
var state: KeyboardState
@ObservedObject var typing: TypingSessionController
let palette: ThemePalette
+1 -1
View File
@@ -12,7 +12,7 @@ struct LastInputEditView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
var state: KeyboardState
@State private var selectedPage: Int? = 0
private var palette: ThemePalette {
@@ -4,7 +4,7 @@
// Verifies that the transient suggestion belongs to one pasteboard generation
// and one keyboard presentation; clipboard history remains independently usable.
import Combine
import Observation
@testable import OSGKeyboardShared
import XCTest
@@ -136,17 +136,19 @@ final class ClipboardSuggestionLifecycleTests: XCTestCase {
func testUnchangedPollingDoesNotRepublishSuggestion() {
copy("stable clipboard")
coordinator.captureIfNeeded()
var publications = 0
let cancellable = state.$clipboardSuggestionText
.dropFirst()
.sink { _ in publications += 1 }
defer { cancellable.cancel() }
let republished = expectation(description: "suggestion republished")
republished.isInverted = true
withObservationTracking {
_ = state.clipboardSuggestionText
} onChange: {
republished.fulfill()
}
coordinator.captureIfNeeded()
coordinator.captureIfNeeded()
coordinator.captureIfNeeded()
XCTAssertEqual(publications, 0)
wait(for: [republished], timeout: 0.1)
XCTAssertEqual(pasteboard.stringReadCount, 1)
}
+7 -9
View File
@@ -13,7 +13,7 @@
// *don't* attempt that here — the State class is what we care about
// for correctness.
import Combine
import Observation
@testable import OSGKeyboardShared
import XCTest
@@ -189,19 +189,17 @@ final class KeyboardStateTests: XCTestCase {
)
state.enabledClipboardSkills = [first]
let published = expectation(description: "resolved skill snapshot published")
var cancellable: AnyCancellable?
cancellable = state.$enabledClipboardSkills
.dropFirst()
.sink { skills in
XCTAssertEqual(skills.first?.customPrompt, "Second prompt")
published.fulfill()
}
withObservationTracking {
_ = state.enabledClipboardSkills
} onChange: {
published.fulfill()
}
state.enabledClipboardSkills = [second]
wait(for: [published], timeout: 1)
XCTAssertEqual(state.enabledClipboardSkills.first?.customPrompt, "Second prompt")
XCTAssertEqual(state.enabledClipboardSkillIDs, ["official.rewrite"])
withExtendedLifetime(cancellable) {}
}
func testReturnKeyRoleActionFillAndTitles() {
+110 -107
View File
@@ -7,13 +7,16 @@
// (`KeyboardViewController`) re-exports the same type as a typealias so
// existing call sites (`KeyboardViewController.State`) keep compiling.
import Combine
import Foundation
import Observation
import SwiftUI
import UIKit
/// `@Observable` so each view re-renders only for the properties it reads —
/// the 200 ms mic `level` tick no longer invalidates the whole keyboard.
@MainActor
public final class KeyboardState: ObservableObject {
@Observable
public final class KeyboardState {
public init() {}
/// Full-height clipboard UI layered over the active keyboard surface.
@@ -89,7 +92,7 @@ public final class KeyboardState: ObservableObject {
public var id: String { rawValue }
}
@Published public var phase: Phase = .idle {
public var phase: Phase = .idle {
didSet {
// A timeline only describes the current processing wait.
if phase != .processing, dictationProgress != nil {
@@ -99,47 +102,47 @@ public final class KeyboardState: ObservableObject {
}
/// Dictation wait shown as the microphone capsule's progress fill.
/// Nil outside dictation processing (AI, edit, and cancellation waits).
@Published public var dictationProgress: DictationProgressTimeline?
public var dictationProgress: DictationProgressTimeline?
/// Active chrome. Forced to `.voice` while recording / processing.
@Published public var surface: Surface = .voice
public var surface: Surface = .voice
/// Surface the user was on when auto mode switched them to `.voice` to show
/// an auto-reply. Restored when the reply is closed; nil when not auto-routed.
public var autoReplyReturnSurface: Surface?
@Published public var level: Double = 0
@Published public var mode: InputMode = .polish
@Published public var localeId: String = "auto"
@Published public var lastTranscript: String = ""
@ObservationIgnored public var autoReplyReturnSurface: Surface?
public var level: Double = 0
public var mode: InputMode = .polish
public var localeId: String = "auto"
public var lastTranscript: String = ""
/// `true` if the active ASR session is running on-device for the
/// current locale. With iOS 26's `SpeechAnalyzer` this is always
/// `true` — kept on the state object because the UI's status
/// badge still wants a single source of truth to read from.
@Published public var onDeviceSupported: Bool = false
public var onDeviceSupported: Bool = false
/// Seconds remaining in the current utterance (Flow tap-to-talk).
@Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
/// Whether the host app's Flow voice session is live and reachable (fresh
/// heartbeat). Do not use the App Group session flag alone for UI gating.
/// Prefer `micVoiceAvailability` for mic color and tap behavior.
@Published public var flowSessionActive: Bool = false
public var flowSessionActive: Bool = false
/// Unified mic color / tap / hint source for the keyboard extension.
@Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
/// Short-lived host-owned practice mode. It unlocks real dictation before
/// onboarding completion, but only while the onboarding text field is live.
@Published public var isOnboardingPracticeActive: Bool = false
public var isOnboardingPracticeActive: Bool = false
/// Current strict OOBE contract used to render feature-specific extension
/// state and bind completion to the host-owned session ID.
@Published public var oobePracticeSession: OOBEPracticeSession?
public var oobePracticeSession: OOBEPracticeSession?
/// When true, the mic is intentionally disabled (e.g. cloud engine
/// selected but the provider-specific API key is missing).
@Published public var micDisabled: Bool = false
public var micDisabled: Bool = false
/// AI mode always needs an LLM even when local ASR keeps voice dictation usable.
@Published public var aiServiceAvailable: Bool = true
public var aiServiceAvailable: Bool = true
/// One-line helper shown above the mic while `micDisabled == true`.
@Published public var micDisabledHint: String = ""
public var micDisabledHint: String = ""
/// "local" → on-device ASR only. "cloud" → cloud ASR + LLM polish.
/// Boot value must match the privacy-safe app default (`local`) so the
/// keyboard never assumes the audio-uploading engine before the App
/// Group config has been read.
@Published public var engineMode: String = "local"
public var engineMode: String = "local"
/// Derived: translation is on iff a target
/// locale has been selected (mirrors `ProviderConfig.translationEnabled`
/// so the chip / pipeline read the same source of truth).
@@ -150,122 +153,122 @@ public final class KeyboardState: ObservableObject {
/// produce (e.g. `"en"`, `"ja"`). Mirrored from `ProviderConfig`.
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
/// state on first install.
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
/// Mirrored from App Group — swaps delete / space on the bottom row.
@Published public var handednessPreference: HandednessPreference = .left
public var handednessPreference: HandednessPreference = .left
/// 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.
@Published public var returnKeyRole: ReturnKeyRole = .newline
public var returnKeyRole: ReturnKeyRole = .newline
/// Opt-in clipboard history capture (mirrored from App Group).
@Published public var clipboardHistoryEnabled: Bool = false
public var clipboardHistoryEnabled: Bool = false
/// Current extension permission state. Clipboard history remains readable
/// without Full Access, but new system pasteboard entries cannot be captured.
@Published public var hasFullAccess: Bool = false
public var hasFullAccess: Bool = false
public var needsClipboardFullAccessHint: Bool {
clipboardHistoryEnabled && !hasFullAccess
}
/// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false
public var clipboardCandidateBarEnabled: Bool = false
/// Opt-in auto mode: a freshly copied replyable message is auto-routed into
/// the Reply flow when the keyboard opens (requires history enabled).
@Published public var clipboardAutoModeEnabled: Bool = false
public var clipboardAutoModeEnabled: Bool = false
/// Opt-in: auto-translate a freshly copied non-system-language paste on open.
@Published public var clipboardAutoTranslateEnabled: Bool = false
public var clipboardAutoTranslateEnabled: Bool = false
/// Opt-in: auto-draft a reply when a freshly copied paste is an email.
@Published public var clipboardAutoEmailReplyEnabled: Bool = false
public var clipboardAutoEmailReplyEnabled: Bool = false
/// When true, the current AI result is a *staged* auto result (auto-translate
/// or auto-email-reply): shown on the keyboard and inserted only when the
/// user taps the glass Insert button — never dropped straight into the field.
@Published public var autoResultReadOnly: Bool = false
public var autoResultReadOnly: Bool = false
/// Localization key for the staged auto result's glass Insert button, so the
/// label matches the action (e.g. "Insert translation" vs "Insert reply").
@Published public var autoResultInsertLabelKey: String = "keyboard.assistant.insertTranslation"
public var autoResultInsertLabelKey: String = "keyboard.assistant.insertTranslation"
/// Clipboard skill that produced the currently shown result (reply / translate
/// / …). The result panel's top bar offers the *other* applicable skills, so
/// this one is excluded from that row. Nil when no clipboard result is active.
@Published public var activeClipboardSkillID: String?
public var activeClipboardSkillID: String?
/// Skills-tab order for clipboard chips. Empty → hint carousel.
@Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
/// Fully resolved enabled skills. Mirroring value-semantic content here
/// ensures Darwin updates publish prompt/name changes even when IDs stay unchanged.
@Published public var enabledClipboardSkills: [AIClipboardSkill] =
public var enabledClipboardSkills: [AIClipboardSkill] =
AIClipboardSkillCatalog.visible()
/// Complete resolved catalog used for semantic recommendations. Keyboard
/// visibility is independent from the Skills page installation state.
@Published public var clipboardSkillCatalog: [AIClipboardSkill] =
public var clipboardSkillCatalog: [AIClipboardSkill] =
AIClipboardSkillCatalog.catalog
/// User-owned speaking style injected only into conversational reply skills.
/// Built-in polish personalities intentionally leave this nil.
@Published public var clipboardReplyStyle: AIClipboardReplyStyleContext?
public var clipboardReplyStyle: AIClipboardReplyStyleContext?
/// App Group setting: one Reply request should return three selectable tones.
@Published public var multipleReplyVariantsEnabled: Bool = false
public var multipleReplyVariantsEnabled: Bool = false
/// Export skills whose companion Shortcut setup the user confirmed.
@Published public var confirmedClipboardShortcutIDs: [String] = []
public var confirmedClipboardShortcutIDs: [String] = []
/// App language captured with the same App Group snapshot as skill copy.
@Published public var uiLanguage: AppUILanguage = .auto
public var uiLanguage: AppUILanguage = .auto
/// Export skill currently waiting on the LLM. Nil for transform skills.
@Published public var pendingClipboardSkillID: String?
public var pendingClipboardSkillID: String?
/// Clipboard captured when that export skill was tapped, so the body
/// still exists after the 30-second hint window closes.
public var pendingClipboardSkillSource: String?
@ObservationIgnored public var pendingClipboardSkillSource: String?
/// In-keyboard toast (e.g. no todos). Does not leave the host app.
@Published public var skillTipText: String?
public var skillTipText: String?
/// Host field is a password / secure entry — never read pasteboard.
@Published public var isSecureTextEntry: Bool = false
public var isSecureTextEntry: Bool = false
/// Secure fields hide every clipboard-history entry point.
public var canShowClipboardEntry: Bool {
!isSecureTextEntry
}
/// Full-keyboard clipboard overlay (enable guide or history list).
@Published public var clipboardOverlay: ClipboardKeyboardOverlay = .none
public var clipboardOverlay: ClipboardKeyboardOverlay = .none
/// Suggestion strip above keys (newest clipboard item).
@Published public var clipboardSuggestionText: String?
public var clipboardSuggestionText: String?
/// Pasteboard changeCount associated with the current suggestion (for dismiss).
@Published public var clipboardSuggestionChangeCount: Int?
public var clipboardSuggestionChangeCount: Int?
/// Newest clipboard entry the user dismissed from the AI skill top bar.
/// Shared (not view-local) so the dismissal survives surface switches —
/// otherwise recreating `AIKeyboardView` would resurrect the skill bar
/// while the paste suggestion stays suppressed. Reset when a new entry
/// arrives so a fresh copy re-shows the skills.
@Published public var dismissedClipboardSkillEntryID: UUID?
public var dismissedClipboardSkillEntryID: UUID?
/// Typing-grid haptic strength (off / light / strong).
@Published public var keyboardHapticIntensity: KeyboardHapticIntensity = .default
public var keyboardHapticIntensity: KeyboardHapticIntensity = .default
/// Single source of truth for selecting iPad-scale keyboard metrics.
/// The view controller resolves this from device idiom + horizontal size
/// class so SwiftUI and the UIKit height constraint cannot disagree.
@Published public var usesIPadLayoutMetrics: Bool = false
public var usesIPadLayoutMetrics: Bool = false
/// The custom system-keyboard switch is iPad-only. iPhone relies on the
/// system-provided switch below the keyboard instead of showing a duplicate.
@Published public var showsSystemGlobeKey: Bool = false
public var showsSystemGlobeKey: Bool = false
/// Width the controller sized the keyboard to. Both the UIKit height
/// constraint and the SwiftUI key grid pick their metrics from this one
/// value so they can never disagree and clip the bottom row.
@Published public var layoutWidth: CGFloat = 0
public var layoutWidth: CGFloat = 0
/// `true` when the last voice insertion is still at the caret and can
/// be undone (suffix-checked against `documentContextBeforeInput`).
@Published public var undoAvailable: Bool = false
public var undoAvailable: Bool = false
/// A verified OSG-generated insertion can be edited by voice.
@Published public var editAvailable: Bool = false
public var editAvailable: Bool = false
/// `true` while an undone voice insertion can be re-applied (redo buffer).
@Published public var redoAvailable: Bool = false
public var redoAvailable: Bool = false
/// `true` when the host field has a non-empty selection (copy enabled).
@Published public var copyAvailable: Bool = false
public var copyAvailable: Bool = false
/// `true` when the host field has a non-empty selection (cut enabled).
@Published public var cutAvailable: Bool = false
public var cutAvailable: Bool = false
/// Closed state machine for long-press editing of the last insertion.
@Published public var editSession: EditSessionState = .inactive
public var editSession: EditSessionState = .inactive
/// AI conversation UI state for the keyboard surface. The host owns the actual messages.
@Published public var aiSession: AISessionState = .inactive
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
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
public var assistantInsertionSucceeded: Bool = false
public var editCanReplaceOriginal: Bool = false
/// Short idle feedback (availability, expiry, missing LLM).
@Published public var editHint: String?
public var editHint: String?
/// Availability hints use the green accent; failures keep warning styling.
@Published public var editHintIsPositive: Bool = false
public var editHintIsPositive: Bool = false
/// Whether translate-and-polish is armed for the current engine.
public var isTranslationEffective: Bool {
translationEnabled
@@ -288,7 +291,7 @@ public final class KeyboardState: ObservableObject {
/// Mirrored from App Group / Keychain. Setup UI lives only in the host
/// app; the keyboard uses this flag to gate voice (mic / Flow cold-start)
/// and prompt a jump back to the app when incomplete.
@Published public var hasCompletedOnboarding: Bool = false
public var hasCompletedOnboarding: Bool = false
public enum ReturnKeyRole: Equatable {
case newline
@@ -379,76 +382,76 @@ public final class KeyboardState: ObservableObject {
}
// Action hooks — injected by the view controller at install time.
public var beginRecording: () -> Void = {}
public var endRecording: () -> Void = {}
public var tapMic: () -> Void = {}
@ObservationIgnored public var beginRecording: () -> Void = {}
@ObservationIgnored public var endRecording: () -> Void = {}
@ObservationIgnored public var tapMic: () -> Void = {}
/// Starts/cancels a bounded host-audio prime from the user's mic touch.
public var setMicTouchActive: (Bool) -> Void = { _ in }
@ObservationIgnored public var setMicTouchActive: (Bool) -> Void = { _ in }
/// Discards the complete normal-dictation round, including late ASR/LLM output.
public var cancelVoiceInput: () -> Void = {}
public var beginEditLastInput: () -> Void = {}
public var stopEditListening: () -> Void = {}
public var confirmEditResult: () -> Void = {}
public var closeEditMode: () -> Void = {}
public var tapAIMic: () -> Void = {}
public var cancelAIInput: () -> Void = {}
@ObservationIgnored public var cancelVoiceInput: () -> Void = {}
@ObservationIgnored public var beginEditLastInput: () -> Void = {}
@ObservationIgnored public var stopEditListening: () -> Void = {}
@ObservationIgnored public var confirmEditResult: () -> Void = {}
@ObservationIgnored public var closeEditMode: () -> Void = {}
@ObservationIgnored public var tapAIMic: () -> Void = {}
@ObservationIgnored public var cancelAIInput: () -> Void = {}
/// Explicitly inserts a retained AI result after target validation failed.
public var confirmPendingAIAnswer: () -> Void = {}
@ObservationIgnored public var confirmPendingAIAnswer: () -> Void = {}
/// Inserts the complete tapped reply card. The UUID remains available on
/// `aiSession.selectedReplyVariant` for a future feedback store.
public var selectAIReplyVariant: (UUID) -> Void = { _ in }
public var discardPendingAIAnswer: () -> Void = {}
@ObservationIgnored public var selectAIReplyVariant: (UUID) -> Void = { _ in }
@ObservationIgnored public var discardPendingAIAnswer: () -> Void = {}
/// Performs the focused field's semantic Return action.
public var performAssistantFieldAction: () -> Void = {}
@ObservationIgnored public var performAssistantFieldAction: () -> Void = {}
/// Sends a tapped idle hint card as the AI question (skip microphone).
public var submitAIHint: (AIHintCard) -> Void = { _ in }
@ObservationIgnored public var submitAIHint: (AIHintCard) -> Void = { _ in }
/// Sends a clipboard skill plus any source-bound Reply scene modifier.
public var submitAIClipboardSkill: (AIClipboardSkill, AIClipboardReplyScene?) -> Void = { _, _ in }
@ObservationIgnored public var submitAIClipboardSkill: (AIClipboardSkill, AIClipboardReplyScene?) -> Void = { _, _ in }
/// Writes extract-todos titles and opens the host to run the Shortcut.
public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
public var openSettings: () -> Void = {}
@ObservationIgnored public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
@ObservationIgnored public var openSettings: () -> Void = {}
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.
public var openInputMethodSetup: () -> Void = {}
@ObservationIgnored public var openInputMethodSetup: () -> Void = {}
/// Opens the host app Settings → Clipboard page (enable history toggle).
public var openClipboardSettings: () -> Void = {}
@ObservationIgnored public var openClipboardSettings: () -> Void = {}
/// Confirms the full-keyboard "try AI auto-reply" guide: turns auto mode on
/// (persisted), dismisses the layer, and drafts a reply for the current copy.
public var tryAutoReplyFromGuide: () -> Void = {}
@ObservationIgnored public var tryAutoReplyFromGuide: () -> Void = {}
/// Top-bar clipboard button: guide when history off, else history panel.
public var openClipboardPanel: () -> Void = {}
public var dismissClipboardOverlay: () -> Void = {}
public var insertClipboardText: (String) -> Void = { _ in }
public var dismissClipboardSuggestion: () -> Void = {}
public var clearClipboardHistory: () -> Void = {}
public var deleteClipboardHistoryEntry: (UUID) -> Void = { _ in }
@ObservationIgnored public var openClipboardPanel: () -> Void = {}
@ObservationIgnored public var dismissClipboardOverlay: () -> Void = {}
@ObservationIgnored public var insertClipboardText: (String) -> Void = { _ in }
@ObservationIgnored public var dismissClipboardSuggestion: () -> Void = {}
@ObservationIgnored public var clearClipboardHistory: () -> Void = {}
@ObservationIgnored public var deleteClipboardHistoryEntry: (UUID) -> Void = { _ in }
/// Notify that the user inserted text (hides suggestion strip).
public var noteUserDidInputText: () -> Void = {}
@ObservationIgnored public var noteUserDidInputText: () -> Void = {}
/// System globe (🌐) key target. Kept weak to avoid a state → controller
/// ownership cycle; UIKit's standard all-touch-events action provides both
/// tap-to-advance and long-press input-mode selection.
public weak var inputModeController: UIInputViewController?
public var startFlowSession: () -> Void = {}
public var setMode: (InputMode) -> Void = { _ in }
public var setLocale: (String) -> Void = { _ in }
public var setEngineMode: (String) -> Void = { _ in }
@ObservationIgnored public weak var inputModeController: UIInputViewController?
@ObservationIgnored public var startFlowSession: () -> Void = {}
@ObservationIgnored public var setMode: (InputMode) -> Void = { _ in }
@ObservationIgnored public var setLocale: (String) -> Void = { _ in }
@ObservationIgnored public var setEngineMode: (String) -> Void = { _ in }
/// Only the locale picker remains; `enabled`
/// is derived from the locale id, so there's no separate toggle to
/// persist. Wired in `KeyboardViewController.installStateActions`.
public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
public var insertNewline: () -> Void = {}
public var insertSpace: () -> Void = {}
public var deleteBackward: () -> Void = {}
@ObservationIgnored public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
@ObservationIgnored public var insertNewline: () -> Void = {}
@ObservationIgnored public var insertSpace: () -> Void = {}
@ObservationIgnored public var deleteBackward: () -> Void = {}
/// Undo the last voice insertion when `undoAvailable` is true.
public var undoLastInsertion: () -> Void = {}
@ObservationIgnored public var undoLastInsertion: () -> Void = {}
/// Redo the last undone voice insertion when `redoAvailable` is true.
public var redoLastInsertion: () -> Void = {}
@ObservationIgnored public var redoLastInsertion: () -> Void = {}
/// Copy the current text selection to the pasteboard.
public var copySelection: () -> Void = {}
@ObservationIgnored public var copySelection: () -> Void = {}
/// Cut the current text selection (copy + delete).
public var cutSelection: () -> Void = {}
@ObservationIgnored public var cutSelection: () -> Void = {}
/// Switch voice ↔ typing. No-ops when voice pipeline is active.
public var setSurface: (Surface) -> Void = { _ in }
@ObservationIgnored public var setSurface: (Surface) -> Void = { _ in }
/// Recording / processing must stay on the voice surface.
public var locksTypingSurface: Bool {