Files
OSGKeyboard/OSGKeyboardExt/Views/KeyboardRootView.swift
T
rocky 93b6aa6c02 feat(translation-polish): dual-engine translation UX + provider picker filter + topbar cleanup
- Add LLMProvider.isUserSelectable (default true) and filter
  ProviderPickerSection on it; next pass can hide non-user presets
  (e.g. a future DeepSeek key-preset) without changing call sites.
- KeyboardRootView: hide TranslationChip when off (matches user's
  mental model of an opt-in feature), drop the 'warming' branch
  (Qwen3 download UX was removed with the backend in v0.2.0), unify
  chip pill height to minHeight 28 + vertical 6 for visual rhythm
  across all topbar chips.
- TranslationChip: drop isLocal warning path — both engines now
  run the translate-and-polish step (local routes through DeepSeek
  via ProviderConfig.localModeProviderId).
- OnboardingView: cloud engine branch now wraps the translation
  row in the same surface card chrome as the local branch.
- Strings: drop keyboard.models.warming (no longer referenced).

DeepSeek key pre-fill deferred to a follow-up.
2026-06-25 15:16:33 +08:00

519 lines
19 KiB
Swift

// 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
// `inputView.allowsSelfSizing = true` in the view controller so SwiftUI's
// frame is honoured, and we add safe-area insets at the top and bottom so
// the system Spotlight / home-indicator chrome never clips our controls.
//
// ┌───────────────────────────────────────────┐
// │ [polish] [中] ● ⚙ │ ← top: ~38 pt (+20%)
// │ (transcript preview) │
// │ │
// │ (⌫) ◯ mic (↩) │ ← action row: circular
// │ (space) │ flanking buttons
// └───────────────────────────────────────────┘
import SwiftUI
import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let sideActionButtonSize: CGFloat = 53
static let sideActionIconSize: CGFloat = 19
static let sideSpaceBarWidth: CGFloat = 19
static let micFlankMinSpacing: CGFloat = 36
static let sideActionStackSpacing: CGFloat = 16
/// Outer inset for delete / return·space from screen edges (8 pt → 24 pt, +200%).
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
}
public struct KeyboardRootView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: State
public init(state: KeyboardViewController.State) {
self.state = state
}
/// Total keyboard height. We set the same value as a height-anchor
/// constraint in the view controller so the host UIInputView picks
/// it up.
static let totalHeight: CGFloat = 280
private static let topBarHeight: CGFloat = 38
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
public var body: some View {
VStack(spacing: 0) {
topBar
.frame(height: Self.topBarHeight)
centreArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.top, 4)
.padding(.bottom, 6)
// 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。
.background(Color.clear)
.frame(height: Self.totalHeight)
// Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette)
}
// MARK: - Top bar
private var topBar: some View {
HStack(spacing: Spacing.xs) {
if state.isLocalEngine {
LocalEngineChip()
} else {
CloudEngineChip()
}
LocaleChip(localeId: state.localeId) { newId in
state.setLocale(newId)
}
// v0.2.1: translation chip — sits next to the locale picker
// and doubles as both the on/off switch and the target-
// language picker (Menu pattern matches LocaleChip so the
// top bar stays visually consistent).
// v0.2.1 final review: only render the chip when translation
// is actually on. Off-by-default keeps the top bar compact
// for users who don't need translation; the menu still lives
// in onboarding so the feature is discoverable.
if state.translationEnabled {
TranslationChip(state: state)
}
Spacer(minLength: 0)
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
Button(action: state.openSettings) {
Image(systemName: "gearshape.fill")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(width: 34, height: 34)
.background(palette.surface, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(ExtL10n.text("keyboard.openSettingsA11y"))
}
.padding(.horizontal, Spacing.md)
}
// MARK: - Centre area
private var centreArea: some View {
VStack(spacing: Spacing.xxs) {
TranscriptLine(
phase: state.phase,
transcript: state.lastTranscript,
flowSessionActive: state.flowSessionActive,
isLocalEngine: state.isLocalEngine,
localModelsReady: state.localModelsReady,
localModelsLoaded: state.localModelsLoaded,
openSettings: state.openSettings,
startFlowSession: state.startFlowSession
)
.frame(height: 22)
Spacer(minLength: 0)
micActionRow
.padding(.bottom, Spacing.xs)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
}
/// Delete (left), mic (centre), return + space stacked on the right.
/// HStack vertical alignment keeps delete, mic centre, and the gap
/// between return/space on one horizontal axis.
private var micActionRow: some View {
HStack(alignment: .center, spacing: 0) {
CircularToolbarButton(systemName: "delete.left", label: "delete") {
state.deleteBackward()
}
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
RecordButton(
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
onToggle: state.tapMic
)
.frame(width: 132, height: 132)
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
VStack(spacing: KeyboardLayoutMetrics.sideActionStackSpacing) {
CircularToolbarButton(systemName: "return", label: "newline") {
state.insertNewline()
}
CircularToolbarButton(spaceStyle: true, label: "space") {
state.insertSpace()
}
}
}
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
.frame(maxWidth: .infinity)
}
private var buttonPhase: RecordButton.Phase {
switch state.phase {
case .idle: return .idle
case .requestingPermissions: return .idle
case .recording: return .recording
case .processing: return .processing
case .error: return .error
case .denied: return .error
}
}
}
// MARK: - State alias
extension KeyboardRootView {
typealias State = KeyboardViewController.State
}
// MARK: - SwiftUI Preview
#if DEBUG
#Preview("Keyboard · Idle") {
KeyboardRootView(state: KeyboardViewController.State.previewIdle)
.frame(width: 390, height: 280)
.preferredColorScheme(.dark)
}
#Preview("Keyboard · Recording") {
KeyboardRootView(state: KeyboardViewController.State.previewRecording)
.frame(width: 390, height: 280)
.preferredColorScheme(.dark)
}
#Preview("Keyboard · Processing") {
KeyboardRootView(state: KeyboardViewController.State.previewProcessing)
.frame(width: 390, height: 280)
.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 flowSessionActive: Bool
let isLocalEngine: Bool
let localModelsReady: Bool
let localModelsLoaded: Bool
let openSettings: () -> Void
let startFlowSession: () -> Void
var body: some View {
ZStack {
switch phase {
case .idle:
if isLocalEngine, !localModelsReady {
Button(action: openSettings) {
HStack(spacing: 4) {
Text(ExtL10n.string("keyboard.models.notDownloaded"))
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .semibold))
}
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.models.downloadHint"))
} else if flowSessionActive {
ExtL10n.text("keyboard.placeholder.idle")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
} else {
HStack(spacing: 6) {
ExtL10n.text("keyboard.flow.sessionInactive")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
Button(action: startFlowSession) {
ExtL10n.text("keyboard.flow.start")
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.flow.startA11y"))
}
}
case .requestingPermissions:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.textSecondary)
ExtL10n.text("keyboard.placeholder.preparing")
.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:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.accent)
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"))
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.md)
}
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")
}
}
}
// MARK: - Circular toolbar button
private struct CircularToolbarButton: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette: ThemePalette
let systemName: String?
let spaceStyle: Bool
let label: String
let action: () -> Void
init(systemName: String, label: String, action: @escaping () -> Void) {
self.systemName = systemName
self.spaceStyle = false
self.label = label
self.action = action
}
init(spaceStyle: Bool, label: String, action: @escaping () -> Void) {
self.systemName = nil
self.spaceStyle = spaceStyle
self.label = label
self.action = action
}
var body: some View {
Button(action: action) {
Group {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: KeyboardLayoutMetrics.sideSpaceBarWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: KeyboardLayoutMetrics.sideActionIconSize, weight: .medium))
.foregroundStyle(palette.textPrimary)
}
}
.frame(width: KeyboardLayoutMetrics.sideActionButtonSize, height: KeyboardLayoutMetrics.sideActionButtonSize)
.background(sideButtonFill, in: Circle())
.overlay(Circle().stroke(palette.dividerStrong, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(Text(label))
}
private var sideButtonFill: Color {
colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
}
}
// MARK: - Status badge
private struct StatusBadge: View {
@Environment(\.themePalette) private var palette: ThemePalette
let phase: KeyboardViewController.State.Phase
/// Reflects whether the active ASR session is on-device. We surface
/// a small ⚠️ during recording so the user knows their audio is
/// going to the cloud for this locale (and so devs catch it during
/// QA without staring at the Xcode console).
let onDeviceSupported: Bool
var body: some View {
Group {
switch phase {
case .idle:
EmptyView()
case .requestingPermissions:
EmptyView()
case .recording:
if onDeviceSupported {
dot(color: palette.recordRed, labelKey: "keyboard.status.rec")
} else {
dot(color: palette.warning, labelKey: "keyboard.status.recWarning", showWarning: true)
}
case .processing:
dot(color: palette.accent, labelKey: "keyboard.status.processing")
case .error:
dot(color: palette.warning, labelKey: "keyboard.status.error")
case .denied:
dot(color: palette.warning, labelKey: "keyboard.status.error")
}
}
}
private func dot(color: Color, labelKey: String, showWarning: Bool = false) -> some View {
HStack(spacing: 4) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
if showWarning {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(palette.warning)
}
Text(ExtL10n.string(labelKey))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.xs)
.padding(.vertical, 4)
.background(palette.surface, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
}
// MARK: - Cloud engine chip (cloud always ASR + LLM polish)
private struct CloudEngineChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
HStack(spacing: 4) {
Image(systemName: "wand.and.stars")
ExtL10n.text("keyboard.placeholder.cloudBadge")
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
}
}
// MARK: - Local engine chip (shown instead of ModeChip when engineMode == "local")
private struct LocalEngineChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark")
ExtL10n.text("keyboard.placeholder.localBadge")
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
}
}
// MARK: - Locale chip
private struct LocaleChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
let localeId: String
let onChange: (String) -> Void
private let options: [(id: String, labelKey: String)] = [
("auto", "locale.chip.auto"),
("zh-Hans", "locale.chip.zh-Hans"),
("zh-Hant", "locale.chip.zh-Hant"),
("en-US", "locale.chip.en-US"),
("ja-JP", "locale.chip.ja-JP"),
("ko-KR", "locale.chip.ko-KR")
]
var body: some View {
Menu {
ForEach(options, id: \.id) { o in
Button {
onChange(o.id)
} label: {
if o.id == localeId {
Label(ExtL10n.string(o.labelKey), systemImage: "checkmark")
} else {
Text(ExtL10n.string(o.labelKey))
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "globe")
Text(currentLabel)
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
.menuStyle(.button)
}
private var currentLabel: String {
options.first(where: { $0.id == localeId }).map { ExtL10n.string($0.labelKey) }
?? ExtL10n.string("locale.chip.auto")
}
}