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
+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