feat: polish scenarios and stabilize keyboard layout height

Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip)
with ScenarioPrompt and style directives; drive keyboard height from
content (240pt) and use viewIsAppearing encapsulated-height offset for
smoother keyboard switches; remove redundant StatusBadge and hide system
dictation via hasDictationKey.
This commit is contained in:
Rocky
2026-06-29 00:08:00 +08:00
parent 4fec0da7f0
commit 1bdb8824ac
32 changed files with 1247 additions and 340 deletions
+105 -31
View File
@@ -74,24 +74,36 @@ public final class KeyboardViewController: UIInputViewController {
private var wasFlowSessionActive = false
private var flowSessionMonitorTask: Task<Void, Never>?
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
private var configDarwinObserver: FlowSessionDarwinObserver?
/// Grace period after a chip-side translation write during which the
/// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`.
private var translationConfigProtectedUntil: Date?
private var polishScenarioConfigProtectedUntil: Date?
private var isAwaitingFlowResult = false
private var lastFlowAutoStartAttempt: TimeInterval = 0
private static let flowAutoStartCooldown: TimeInterval = 20
/// Drives the keyboard slot height on `view` (priority 999).
private var keyboardHeightConstraint: NSLayoutConstraint?
/// Runtime value read from `UIView-Encapsulated-Layout-Height` (varies by device).
private var systemEncapsulatedHeight: CGFloat = 228
private var targetKeyboardHeight: CGFloat {
KeyboardRootView.totalHeight
}
// MARK: - Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
// Keyboard extension MUST opt in to self-sizing, otherwise
// our SwiftUI `frame(height:)` is ignored and the keyboard is
// cropped by the system chrome (Spotlight bar, home indicator).
inputView?.allowsSelfSizing = true
installKeyboardHeight()
configureDictationBehavior()
installStateActions()
installSwiftUI()
loadPersistedConfig()
consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded()
installFlowSessionDarwinObserver()
installConfigDarwinObserver()
refreshFlowSessionState()
}
@@ -108,6 +120,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded()
@@ -115,6 +128,17 @@ public final class KeyboardViewController: UIInputViewController {
startFlowSessionMonitor()
}
public override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
applyPresentationHeightOffset()
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Presentation finished lock to the true content-driven height.
keyboardHeightConstraint?.constant = targetKeyboardHeight
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
cancelPipeline()
@@ -126,6 +150,14 @@ public final class KeyboardViewController: UIInputViewController {
refreshDictationProgressStateIfNeeded()
}
// MARK: - System keyboard chrome
/// Tell iOS this keyboard provides its own dictation entry (centre mic).
/// When `true`, the system dictation key in the bottom-right is not shown.
private func configureDictationBehavior() {
hasDictationKey = true
}
// MARK: - Wiring
private func installStateActions() {
@@ -141,16 +173,49 @@ public final class KeyboardViewController: UIInputViewController {
// v0.2.1 follow-up: removed `setTranslationEnabled` the chip
// / picker only writes the locale id now; `enabled` is derived.
state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) }
state.setPolishScenarioId = { [weak self] id in self?.persistPolishScenarioId(id) }
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
}
/// Reserve keyboard height on `view`. During presentation iOS adds a
/// private encapsulated height; `viewIsAppearing` applies the community
/// offset trick (target encapsulated) so the slot lands at `target`.
private func installKeyboardHeight() {
let constraint = view.heightAnchor.constraint(
equalToConstant: targetKeyboardHeight
)
constraint.priority = UILayoutPriority(999)
constraint.isActive = true
keyboardHeightConstraint = constraint
}
/// Read the system encapsulated height and prime our constraint so iOS
/// presentation math (custom + encapsulated) equals `targetKeyboardHeight`.
/// See: https://developer.apple.com/forums/thread/799003
private func applyPresentationHeightOffset() {
if let encapsulated = view.constraints.first(where: { constraint in
constraint.firstItem as? UIView === view
&& constraint.firstAttribute == .height
&& constraint !== keyboardHeightConstraint
}) {
systemEncapsulatedHeight = encapsulated.constant
}
let primed = targetKeyboardHeight - systemEncapsulatedHeight
keyboardHeightConstraint?.constant = max(0, primed)
}
private func installSwiftUI() {
let root = KeyboardRootView(state: state)
let host = UIHostingController(rootView: root)
host.view.backgroundColor = .clear
host.view.translatesAutoresizingMaskIntoConstraints = false
host.view.clipsToBounds = false
// Keep keyboard layout anchored to the top edge across keyboard
// switches don't let UIHostingController re-inset for safe area.
host.view.insetsLayoutMarginsFromSafeArea = false
host.safeAreaRegions = []
addChild(host)
view.addSubview(host.view)
NSLayoutConstraint.activate([
@@ -158,11 +223,6 @@ public final class KeyboardViewController: UIInputViewController {
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
host.view.topAnchor.constraint(equalTo: view.topAnchor),
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
// Pin the host view to a fixed height matching KeyboardRootView.totalHeight.
// Without this, iOS lets the system chrome (Spotlight, home
// indicator) bleed into our content. With it, our content area
// is fully reserved and the keyboard feels intentional.
host.view.heightAnchor.constraint(equalToConstant: KeyboardRootView.totalHeight)
])
host.didMove(toParent: self)
self.hosting = host
@@ -203,8 +263,28 @@ public final class KeyboardViewController: UIInputViewController {
flowSessionMonitorTask = nil
}
private func installConfigDarwinObserver() {
configDarwinObserver = FlowSessionDarwinObserver(
notificationName: AppGroupConfigDarwin.notificationName
) { [weak self] in
self?.refreshConfigFromAppGroup()
}
}
private func refreshConfigFromAppGroup() {
persistor.refreshRuntimeFlags(
into: state,
protectTranslationUntil: translationConfigProtectedUntil,
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
)
}
private func refreshFlowSessionState() {
persistor.refreshRuntimeFlags(into: state)
persistor.refreshRuntimeFlags(
into: state,
protectTranslationUntil: translationConfigProtectedUntil,
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
)
consumePendingFlowDeliveryIfNeeded()
let active = FlowSessionBridge.isSessionActive()
@@ -515,8 +595,9 @@ public final class KeyboardViewController: UIInputViewController {
debug("received transcript length=\(trimmed.count)")
awaitingDictationResult = false
stopDictationWatchdog()
// Local engine: host app delivers raw ASR transcript; insert as-is.
if state.isLocalEngine {
let runtimeStore = AppGroupStore()
guard runtimeStore.shouldRunCloudLLMStep else {
textDocumentProxy.insertText(trimmed)
state.lastTranscript = ""
if let warning = delivery.polishWarning {
@@ -527,28 +608,13 @@ public final class KeyboardViewController: UIInputViewController {
}
return
}
// Cloud engine: always polish via the configured LLM.
// Cloud engine, or local engine with cloud polish / translation.
state.phase = .processing
Task { @MainActor [weak self] in
guard let self else { return }
// v0.2.1: pick the polish mode once at task start so a
// mid-flight toggle flip doesn't change the request we
// already sent. `isTranslationEffective` no longer gates on
// `engineMode == "cloud"` the row visibility predicate
// already keeps the picker honest, and the local engine's
// translate-and-polish path now routes through DeepSeek.
let polishMode: PolishingService.PolishMode = self.state.isTranslationEffective
? .translate(targetLocaleId: self.state.translationTargetLocaleId)
: .polish
// v0.2.1: local engine routes through DeepSeek for the
// polish / translate step regardless of the user's chosen
// cloud provider DeepSeek is cheap and strong on
// Chinese, which is the dominant input for the on-device
// ASR transcript. Cloud engine honors the user's own
// provider id by passing `nil`.
let overrideProviderId: String? = self.state.engineMode == "local"
? "deepseek"
: nil
let polishMode = runtimeStore.polishModeForPipeline
let overrideProviderId = runtimeStore.polishProviderIdOverride
do {
let polished = try await self.polisher.polish(
trimmed,
@@ -656,9 +722,17 @@ public final class KeyboardViewController: UIInputViewController {
private func persistTranslationTargetLocaleId(_ id: String) {
let resolved = TranslationLanguageCatalog.resolve(id).id
state.translationTargetLocaleId = resolved
translationConfigProtectedUntil = Date().addingTimeInterval(2.5)
persistor.persist(translationTargetLocaleId: resolved)
}
private func persistPolishScenarioId(_ id: String) {
let resolved = PolishScenarioCatalog.resolve(id).id
state.polishScenarioId = resolved
polishScenarioConfigProtectedUntil = Date().addingTimeInterval(2.5)
persistor.persist(polishScenarioId: resolved)
}
// MARK: - Open host app
private func openHostApp(path: String = "settings") {
@@ -40,6 +40,8 @@ public struct AppGroupPersistor {
// startup; `refreshRuntimeFlags` keeps the chip in sync while
// the keyboard stays open.
state.translationTargetLocaleId = store.translationTargetLocaleId
state.polishScenarioId = store.polishScenarioId
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
// into the State flags so downstream consumers see the same
// shape they did when the previous Qwen3 stack reported "ready".
@@ -75,14 +77,28 @@ public struct AppGroupPersistor {
/// Lightweight refresh for flags the host app may update while the
/// keyboard stays open (model downloads, engine switches).
public func refreshRuntimeFlags(into state: KeyboardViewController.State) {
///
/// When `protectTranslationUntil` is in the future, the translation
/// target locale is not overwritten avoids the 1 Hz poll clobbering
/// a chip selection the user just wrote to the App Group.
public func refreshRuntimeFlags(
into state: KeyboardViewController.State,
protectTranslationUntil: Date? = nil,
protectPolishScenarioUntil: Date? = nil
) {
guard AppGroup.isAvailable else { return }
let store = AppGroupStore()
state.engineMode = store.engineMode
state.localASRBackend = store.localASRBackend
// v0.2.1 follow-up: same as `load` only the locale is
// persisted, `enabled` is derived.
state.translationTargetLocaleId = store.translationTargetLocaleId
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
if !shouldProtectTranslation {
state.translationTargetLocaleId = store.translationTargetLocaleId
}
let shouldProtectScenario = protectPolishScenarioUntil.map { Date() < $0 } ?? false
if !shouldProtectScenario {
state.polishScenarioId = store.polishScenarioId
}
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
// toggles here so the keyboard UI doesn't flicker if the host
// app briefly clears them while refactoring.
@@ -127,4 +143,9 @@ public struct AppGroupPersistor {
guard AppGroup.isAvailable else { return }
AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId)
}
public func persist(polishScenarioId: String) {
guard AppGroup.isAvailable else { return }
AppGroupStore().setPolishScenarioId(polishScenarioId)
}
}
+81 -114
View File
@@ -3,16 +3,17 @@
//
// 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.
// `KeyboardViewController` drives height on `view` (priority 999) and mirrors
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI see presentation offset
// in `applyPresentationHeightOffset()`.
//
//
// [polish] [] top: ~38 pt (+20%)
// [polish] [] header band (top)
// (transcript preview)
//
// () mic () action row: circular
// (space) flanking buttons
//
// () mic () action cluster:
// (space) centred below header
//
//
import SwiftUI
@@ -24,8 +25,33 @@ private enum KeyboardLayoutMetrics {
static let sideSpaceBarWidth: CGFloat = 19
static let micFlankMinSpacing: CGFloat = 36
static let sideActionStackSpacing: CGFloat = 16
/// Gap between the top chip row and the transcript / hint line (4 pt 8 pt, +100%).
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs
/// Outer inset for delete / return·space from screen edges (8 pt 24 pt, +200%).
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
// MARK: - Content-driven keyboard height (single source of truth)
static let outerPaddingTop: CGFloat = 2
static let outerPaddingBottom: CGFloat = 6
static let topBarHeight: CGFloat = 38
static let transcriptLineHeight: CGFloat = 22
static let actionClusterHeight: CGFloat = 132
/// Fixed breathing room above/below the mic row (not flexible Spacers).
static let actionClusterVerticalGap: CGFloat = Spacing.md
static var headerBandHeight: CGFloat {
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
}
/// 2 + 68 + 16 + 132 + 16 + 6 = 240 pt
static var totalHeight: CGFloat {
outerPaddingTop
+ headerBandHeight
+ actionClusterVerticalGap
+ actionClusterHeight
+ actionClusterVerticalGap
+ outerPaddingBottom
}
}
public struct KeyboardRootView: View {
@@ -37,11 +63,9 @@ public struct KeyboardRootView: View {
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
/// Content-driven keyboard height; mirrored on `UIInputViewController.view`
/// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`).
static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
@@ -49,14 +73,19 @@ public struct KeyboardRootView: View {
public var body: some View {
VStack(spacing: 0) {
topBar
.frame(height: Self.topBarHeight)
headerBand
centreArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
micActionRow
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
}
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
// chrome
.background(Color.clear)
.frame(height: Self.totalHeight)
@@ -64,6 +93,26 @@ public struct KeyboardRootView: View {
.environment(\.themePalette, palette)
}
/// Top chip 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,
flowSessionActive: state.flowSessionActive,
isLocalEngine: state.isLocalEngine,
localModelsReady: state.localModelsReady,
localModelsLoaded: state.localModelsLoaded,
openSettings: state.openSettings,
startFlowSession: state.startFlowSession
)
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
}
}
// MARK: - Top bar
private var topBar: some View {
@@ -73,22 +122,20 @@ public struct KeyboardRootView: View {
} else {
CloudEngineChip()
}
if state.isPolishScenarioChipVisible {
ScenarioChip(state: state)
}
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 {
// v0.3: always show the translation chip when the active
// engine can run the cloud LLM step off-by-default keeps
// the menu reachable so the user can pick a target language
// without opening Settings.
if state.isTranslationChipVisible {
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))
@@ -103,35 +150,11 @@ public struct KeyboardRootView: View {
.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)
}
// MARK: - Action cluster
/// 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.
/// Fixed vertical gaps in `body` keep the cluster centred without
/// flexible Spacers consuming extra keyboard height.
private var micActionRow: some View {
HStack(alignment: .center, spacing: 0) {
CircularToolbarButton(systemName: "delete.left", label: "delete") {
@@ -186,19 +209,19 @@ extension KeyboardRootView {
#if DEBUG
#Preview("Keyboard · Idle") {
KeyboardRootView(state: KeyboardViewController.State.previewIdle)
.frame(width: 390, height: 280)
.frame(width: 390, height: KeyboardRootView.totalHeight)
.preferredColorScheme(.dark)
}
#Preview("Keyboard · Recording") {
KeyboardRootView(state: KeyboardViewController.State.previewRecording)
.frame(width: 390, height: 280)
.frame(width: 390, height: KeyboardRootView.totalHeight)
.preferredColorScheme(.dark)
}
#Preview("Keyboard · Processing") {
KeyboardRootView(state: KeyboardViewController.State.previewProcessing)
.frame(width: 390, height: 280)
.frame(width: 390, height: KeyboardRootView.totalHeight)
.preferredColorScheme(.dark)
}
#endif
@@ -367,62 +390,6 @@ private struct CircularToolbarButton: View {
}
}
// 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 {
+13 -4
View File
@@ -42,11 +42,20 @@ struct RecordButton: View {
return remainingSeconds <= 10
}
/// Decorative rings are sized to stay inside the 132 pt frame applied
/// by `KeyboardRootView` so glow / breath animations are not clipped.
private enum Layout {
static let disc: CGFloat = 104
static let outerRing: CGFloat = 112
static let breathRing: CGFloat = 108
static let glow: CGFloat = 128
}
var body: some View {
ZStack {
Circle()
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
.frame(width: 150, height: 150)
.frame(width: Layout.breathRing, height: Layout.breathRing)
.scaleEffect(breath ? 1.18 : 0.95)
.opacity(phase == .recording ? 1 : 0)
.animation(Motion.breath, value: breath)
@@ -60,7 +69,7 @@ struct RecordButton: View {
endRadius: 100
)
)
.frame(width: 200, height: 200)
.frame(width: Layout.glow, height: Layout.glow)
.opacity(phase == .recording ? 0.4 + level * 0.6 : 0)
.blur(radius: 18)
.animation(Motion.soft, value: phase)
@@ -71,7 +80,7 @@ struct RecordButton: View {
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
lineWidth: 0.5
)
.frame(width: 140, height: 140)
.frame(width: Layout.outerRing, height: Layout.outerRing)
ZStack {
Circle()
@@ -115,7 +124,7 @@ struct RecordButton: View {
}
}
}
.frame(width: 120, height: 120)
.frame(width: Layout.disc, height: Layout.disc)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: remainingSeconds)
}
+59
View File
@@ -0,0 +1,59 @@
// ScenarioChip.swift
// OSGKeyboard · Keyboard Extension
//
// Compact chip on the keyboard top bar for quick polish scenario
// switching. Same Menu pattern as `TranslationChip`.
import SwiftUI
import OSGKeyboardShared
struct ScenarioChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var state: KeyboardViewController.State
var body: some View {
Menu {
ForEach(PolishScenarioCatalog.all) { scenario in
Button {
state.setPolishScenarioId(scenario.id)
} label: {
if scenario.id == state.polishScenarioId {
Label(displayLabel(for: scenario), systemImage: "checkmark")
} else {
Text(displayLabel(for: scenario))
}
}
}
} label: {
label
}
.menuStyle(.button)
.accessibilityLabel(ExtL10n.text("keyboard.scenario.a11y"))
.accessibilityHint(ExtL10n.text("keyboard.scenario.a11yHint"))
}
private var label: some View {
HStack(spacing: 4) {
Image(systemName: "text.bubble")
Text(chipText)
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))
}
private var chipText: String {
PolishScenarioCatalog.chipLabel(for: state.polishScenarioId)
}
private func displayLabel(for scenario: PolishScenario) -> String {
PolishScenarioCatalog.displayName(for: scenario.id)
}
}
+3 -3
View File
@@ -19,7 +19,7 @@
// off / on, with the same accent treatment either way.
//
// Visual states:
// off dim outline, "" label
// off dim outline, "" chip label (menu first row = "")
// on (any engine) accent fill, " EN" / " " style label
//
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
@@ -88,14 +88,14 @@ struct TranslationChip: View {
private func displayLabel(for language: TranslationLanguage) -> String {
if language.id == TranslationLanguageCatalog.offLocaleId {
return ExtL10n.string("keyboard.translation.off")
return ExtL10n.string("keyboard.translation.offMenu")
}
return language.nativeName
}
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
if !enabled {
return ExtL10n.string("keyboard.translation.off")
return ExtL10n.string("keyboard.translation.chip")
}
// Short form: "EN" / "" style. Falls back to the prompt
// language name for languages without a chip-style abbreviation
+6 -2
View File
@@ -176,12 +176,16 @@
"locale.chip.ja-JP" = "日";
"locale.chip.ko-KR" = "韩";
/* Translation chip (v0.2.1) */
"keyboard.translation.off" = "Translate";
/* Translation chip (v0.3) */
"keyboard.translation.chip" = "Translate";
"keyboard.translation.offMenu" = "Don't translate";
"keyboard.translation.off" = "Don't translate";
"keyboard.translation.enable" = "Enable translation";
"keyboard.translation.disable" = "Disable translation";
"keyboard.translation.a11y" = "Translation";
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
"keyboard.scenario.a11y" = "Polish scenario";
"keyboard.scenario.a11yHint" = "Choose how dictation is polished.";
/* Mode chip labels (used in both ext + preview stub) */
"mode.off" = "Off";
@@ -76,7 +76,7 @@
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
"settings.engine.cloud.title" = "云端识别与润色";
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。";
"settings.provider.title" = "提供商";
"settings.provider.title" = "云端引擎";
"settings.provider.subtitle" = "选择 LLM 提供商。";
"settings.api.title" = "接口";
"settings.language.title" = "语言";
@@ -176,12 +176,16 @@
"locale.chip.ja-JP" = "日";
"locale.chip.ko-KR" = "韩";
/* 翻译 chip (v0.2.1) */
"keyboard.translation.off" = "翻译";
/* Translation chip (v0.3) */
"keyboard.translation.chip" = "翻译";
"keyboard.translation.offMenu" = "不翻译";
"keyboard.translation.off" = "不翻译";
"keyboard.translation.enable" = "开启翻译";
"keyboard.translation.disable" = "关闭翻译";
"keyboard.translation.a11y" = "翻译";
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
"keyboard.scenario.a11y" = "润色场景";
"keyboard.scenario.a11yHint" = "选择润色风格或使用场景。";
/* Mode chip labels */
"mode.off" = "关闭";