Files
OSGKeyboard/OSGKeyboardExt/Typing/KeyboardSurfaceRoot.swift
T
Rocky 4dce67df84 chore(release): bump version to 2.1.6 (build 131)
Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs,
docs/llms.txt).

fix(flow): host-app identification now compiles in every build configuration
(Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were
DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and
could not auto-return after a startflow handoff. Debug-only logging and the
Home test card are unchanged.

Also includes the pending work in the tree:
- Home: single account/setup card surface (HomeAccountSurfacePolicy/Views)
- Wide screens: voice key row and centred mic placement, iPhone Duo layout
- Keyboard: clipboard skill ranking and AI skill layout updates
- Debug: account session handoff to the Simulator (+ import script)
- Docs: portfolio assets, privacy policy URL, site/README refresh
2026-10-29 11:54:13 +08:00

162 lines
6.2 KiB
Swift

// KeyboardSurfaceRoot.swift
// OSGKeyboard · Keyboard Extension
//
// Switches between voice and typing surfaces driven by KeyboardState.surface.
import OSGKeyboardShared
import SwiftUI
struct KeyboardSurfaceRoot: View {
@Environment(\.colorScheme) private var colorScheme
@Namespace private var keyboardTabSelectionNamespace
var state: KeyboardState
@ObservedObject var typing: TypingSessionController
var onInsert: (String) -> Void
var onDeleteBackward: () -> Void
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
/// True only for overlays that replace the keyboard surface. The auto-reply
/// guide is excluded — it dims the keyboard but keeps it visible underneath.
private var surfacesHidden: Bool {
switch state.clipboardOverlay {
case .enableGuide, .historyPanel:
return true
case .none, .autoReplyGuide:
return false
}
}
/// Height is deliberately independent of the surface: the voice surface
/// adopts the typing surface's content-driven height and parks the surplus
/// above its action cluster, so switching surfaces never resizes the
/// keyboard. Keeping this a single expression is what guarantees it.
static func height(
for surface: KeyboardState.Surface,
isIPad: Bool = false,
width: CGFloat = 0,
isExpandedPhone: Bool = false,
isCompactHeightPhone: Bool = false
) -> CGFloat {
TypingSurfaceMetrics.contentHeight(
isIPad: isIPad,
width: width,
isExpandedPhone: isExpandedPhone,
isCompactHeightPhone: isCompactHeightPhone
)
}
var body: some View {
ZStack {
Group {
switch state.surface {
case .voice:
AIKeyboardView(
state: state,
typing: typing,
onInsert: wrappedInsert
)
case .typing:
TypingRootView(
state: state,
typing: typing,
onInsert: wrappedInsert,
onDeleteBackward: wrappedDeleteBackward
)
case .ai:
// Legacy persisted surface; controller canonicalizes it to
// `.voice`, but render the unified assistant defensively.
AIKeyboardView(
state: state,
typing: typing,
onInsert: wrappedInsert
)
}
}
// The enable-guide / history panel replace the surface; the
// auto-reply guide instead layers over the still-visible keyboard.
.opacity(surfacesHidden ? 0 : 1)
.allowsHitTesting(!surfacesHidden)
// Match every surface's outer chrome so the panel title / X sit in
// the same slot as the logo + clipboard chip (not 4 pt higher).
clipboardOverlayLayer
.padding(.top, TypingSurfaceMetrics.outerPaddingTop)
.padding(.bottom, TypingSurfaceMetrics.outerPaddingBottom)
// Scrim-based nudge sits on top of the live keyboard, no padding so
// the dim reaches every edge.
if state.clipboardOverlay == .autoReplyGuide, state.canShowClipboardEntry {
ClipboardAutoReplyGuideView(
onClose: state.dismissClipboardOverlay,
onTry: state.tryAutoReplyFromGuide
)
}
}
// Overlays are siblings of the surfaces, so the palette has to be
// injected here or they fall back to the environment's dark default.
.environment(\.themePalette, palette)
// The input surfaces are replaced when switching tabs. Keep one
// namespace above that switch so the selected glass pill can morph
// between the outgoing and incoming top-control instances.
.environment(\.keyboardTabSelectionNamespace, keyboardTabSelectionNamespace)
// Bottom-anchored on purpose: UIKit hands the input view a container up
// to the full screen height while the keyboard slides in, and centering
// a fixed-height surface in it parks the whole keyboard above the
// visible slot — which reads as a blank keyboard whenever that frame
// lingers (a slow Universal Clipboard read, a system alert).
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.animation(.easeInOut(duration: 0.15), value: state.surface)
}
private func wrappedInsert(_ text: String) {
if !text.isEmpty {
state.noteUserDidInputText()
}
onInsert(text)
}
private func wrappedDeleteBackward() {
state.noteUserDidInputText()
onDeleteBackward()
}
@ViewBuilder
private var clipboardOverlayLayer: some View {
if !state.canShowClipboardEntry {
EmptyView()
} else {
switch state.clipboardOverlay {
case .none:
EmptyView()
case .enableGuide:
ClipboardEnableGuideView(
onClose: state.dismissClipboardOverlay,
onOpenSettings: {
state.dismissClipboardOverlay()
state.openClipboardSettings()
}
)
case .historyPanel:
ClipboardHistoryPanelView(
history: ClipboardHistoryStore.shared,
onClose: state.dismissClipboardOverlay,
onClear: state.clearClipboardHistory,
onInsert: { state.insertClipboardText($0) },
onDelete: { state.deleteClipboardHistoryEntry($0) },
pastePermissionHint: state.needsClipboardFullAccessHint
? ExtL10n.string("keyboard.clipboard.panel.fullAccessHint")
: nil
)
case .autoReplyGuide:
// Rendered as a scrim layer over the live keyboard, not here.
EmptyView()
}
}
}
}