feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
+111 -25
View File
@@ -1,8 +1,8 @@
// AIKeyboardView.swift
// OSGKeyboard · Keyboard Extension
//
// Temporary voice-to-AI surface. The latest answer remains visible while a
// follow-up is running and is inserted only through the explicit Send action.
// Product voice-to-AI conversation surface. The latest answer remains visible
// while a follow-up runs and is inserted only through the explicit Send action.
import SwiftUI
import OSGKeyboardShared
@@ -14,13 +14,23 @@ struct AIKeyboardView: View {
static let actionButtonHeight: CGFloat = 50
static let actionButtonMaxWidth: CGFloat = 150
static let statusHeight: CGFloat = 20
static let carouselInterval: TimeInterval = 4
}
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
/// A copy made while the keyboard is visible must reach the carousel
/// immediately, not on the next rotation tick.
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
let onInsert: (String) -> Void
@State private var currentHint: AIHintCard?
@State private var hintOpacity: Double = 1
@State private var carouselBag = AIHintCarouselBag()
@State private var poolCards: [AIHintCard] = []
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
@@ -37,6 +47,26 @@ struct AIKeyboardView: View {
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
.environment(\.themePalette, palette)
.onAppear { resetCarousel() }
.onChange(of: state.aiSession.phase) { _, phase in
guard phase == .idle || phase == .failed else { return }
resetCarousel()
}
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
.onReceive(
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
) { _ in
guard showsPlaceholder else { return }
// Reduce Motion stops the rotation, not the data: a card whose
// clipboard window has closed must still leave the carousel.
reloadHintPool(resetBag: false)
if reduceMotion, let hint = currentHint,
poolCards.contains(where: { $0.id == hint.id }) {
return
}
showNextHint(animated: !reduceMotion)
}
}
private var resolvedHeight: CGFloat {
@@ -69,7 +99,9 @@ struct AIKeyboardView: View {
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
} else if state.canShowClipboardEntry,
let suggestion = state.clipboardSuggestionText,
!suggestion.isEmpty {
// Replaces logo + capsule tabs until dismissed.
ClipboardSuggestionBar(
text: suggestion,
@@ -95,12 +127,7 @@ struct AIKeyboardView: View {
private var answerArea: some View {
ZStack(alignment: .bottom) {
if showsPlaceholder {
// Empty-state tip: geometric center of the answer plane.
Text(ExtL10n.string("keyboard.ai.placeholder"))
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.md)
hintCarousel
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
@@ -141,7 +168,36 @@ struct AIKeyboardView: View {
}
}
/// No draft/answer yet show the centered mic guidance instead of a scroll body.
private var hintCarousel: some View {
Button {
guard let hint = currentHint else { return }
state.submitAIHint(hint)
} label: {
Text(currentHint?.displayText ?? ExtL10n.string("keyboard.ai.placeholder"))
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, Spacing.md)
.opacity(hintOpacity)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// A busy session already owns the surface; the status line explains a
// missing LLM. Both keep the hint from being a tap with no outcome.
.disabled(currentHint == nil || !state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(
Text(
currentHint.map {
"\(ExtL10n.string("keyboard.ai.hintA11yPrefix"))\($0.displayText)"
} ?? ExtL10n.string("keyboard.ai.placeholder")
)
)
}
/// No draft/answer yet show the centered hint carousel instead of a scroll body.
private var showsPlaceholder: Bool {
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
return !hasDraft && state.aiSession.answer == nil
@@ -173,7 +229,7 @@ struct AIKeyboardView: View {
private var aiMicrophoneButton: some View {
Button(action: state.tapAIMic) {
ZStack {
Capsule().fill(palette.accent)
Color.clear
if state.aiSession.phase == .listening {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
@@ -187,6 +243,8 @@ struct AIKeyboardView: View {
minHeight: Layout.actionButtonHeight,
maxHeight: Layout.actionButtonHeight
)
//
.background(palette.accent, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -228,11 +286,8 @@ struct AIKeyboardView: View {
minHeight: Layout.actionButtonHeight,
maxHeight: Layout.actionButtonHeight
)
.background(
answerActionFill,
in: Capsule()
)
.overlay(Capsule().stroke(answerActionBorder, lineWidth: 0.5))
//
.background(answerActionFill, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -269,11 +324,11 @@ struct AIKeyboardView: View {
private var answerActionFill: Color {
guard state.aiSession.canPerformAnswerAction else {
return palette.surfaceElevated
return palette.surfaceElevated.opacity(0.55)
}
return state.aiSession.canSend
? palette.accent
: NativeKeyboardKeyColors.fill(for: colorScheme)
: palette.surfaceElevated
}
private var answerActionForeground: Color {
@@ -285,13 +340,6 @@ struct AIKeyboardView: View {
: NativeKeyboardKeyColors.text(for: colorScheme)
}
private var answerActionBorder: Color {
guard state.aiSession.canSend else {
return palette.divider
}
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
}
private var microphoneDisabled: Bool {
switch state.aiSession.phase {
case .preparing, .recognizing, .generating:
@@ -339,4 +387,42 @@ struct AIKeyboardView: View {
? "keyboard.ai.stopA11y"
: "keyboard.ai.startA11y"
}
// MARK: - Carousel
/// Rebuild the pool and show a card right away, without a fade.
private func resetCarousel() {
reloadHintPool(resetBag: true)
showNextHint(animated: false)
}
private func reloadHintPool(resetBag: Bool) {
let locale = AIHintLocaleResolver.packLocale()
let pack = AIHintStore.resolvedPack(locale: locale)
poolCards = AIHintPool.activeCards(
pack: pack,
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: clipboardHistory.newestEntry
)
if resetBag {
carouselBag.reset()
}
}
private func showNextHint(animated: Bool) {
guard let next = carouselBag.next(from: poolCards) else {
currentHint = nil
return
}
if animated, !reduceMotion {
withAnimation(Motion.soft) { hintOpacity = 0 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
currentHint = next
withAnimation(Motion.soft) { hintOpacity = 1 }
}
} else {
currentHint = next
hintOpacity = 1
}
}
}
+118 -52
View File
@@ -124,6 +124,7 @@ struct ClipboardEnableGuideView: View {
struct ClipboardHistoryPanelView: View {
@Environment(\.themePalette) private var palette
@ObservedObject var history: ClipboardHistoryStore
@State private var showClearConfirmation = false
let onClose: () -> Void
let onClear: () -> Void
@@ -132,57 +133,120 @@ struct ClipboardHistoryPanelView: View {
let pastePermissionHint: String?
var body: some View {
VStack(spacing: 0) {
ClipboardPanelHeader(onClose: onClose) {
Button(action: onClear) {
Image(systemName: "trash")
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(
width: KeyboardTopBarMetrics.trailingChipSize,
height: KeyboardTopBarMetrics.trailingChipSize
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(history.entries.isEmpty)
.opacity(history.entries.isEmpty ? 0.35 : 1)
}
if let pastePermissionHint, !pastePermissionHint.isEmpty {
Text(pastePermissionHint)
.font(.system(size: 12))
.foregroundStyle(palette.warning)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.bottom, 6)
}
if history.entries.isEmpty {
ExtL10n.text("keyboard.clipboard.panel.empty")
.font(.system(size: 14))
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(history.entries) { entry in
ClipboardHistoryRow(
entry: entry,
onInsert: { onInsert(entry.text) },
onInsertToken: { onInsert($0) },
onDelete: { onDelete(entry.id) }
)
}
ZStack {
VStack(spacing: 0) {
ClipboardPanelHeader(onClose: onClose) {
Button {
showClearConfirmation = true
} label: {
Image(systemName: "trash")
.font(.system(
size: KeyboardTopBarMetrics.trailingChipIconSize,
weight: .medium
))
.foregroundStyle(palette.textSecondary)
// HIG minimum hit target; icon stays visually small and centered.
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
.buttonStyle(.plain)
.disabled(history.entries.isEmpty)
.opacity(history.entries.isEmpty ? 0.35 : 1)
}
if let pastePermissionHint, !pastePermissionHint.isEmpty {
Text(pastePermissionHint)
.font(.system(size: 12))
.foregroundStyle(palette.warning)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.bottom, 6)
}
if history.entries.isEmpty {
ExtL10n.text("keyboard.clipboard.panel.empty")
.font(.system(size: 14))
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(history.entries) { entry in
ClipboardHistoryRow(
entry: entry,
onInsert: { onInsert(entry.text) },
onInsertToken: { onInsert($0) },
onDelete: { onDelete(entry.id) }
)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
}
}
.allowsHitTesting(!showClearConfirmation)
if showClearConfirmation {
clearConfirmationOverlay
.transition(.scale(scale: 0.96).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Transparent let the system keyboard chrome show through.
.background(Color.clear)
.animation(.easeOut(duration: 0.16), value: showClearConfirmation)
}
private var clearConfirmationOverlay: some View {
VStack(spacing: 12) {
Image(systemName: "trash")
.font(.system(size: 19, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(width: 38, height: 38)
.background(palette.surface.opacity(0.35), in: Circle())
ExtL10n.text("keyboard.clipboard.clear.title")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
HStack(spacing: 10) {
Button {
showClearConfirmation = false
} label: {
ExtL10n.text("common.cancel")
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.glass)
.buttonBorderShape(.capsule)
Button {
// Dismiss the popup before publishing an empty history
// so the keyboard never retains stale row content.
showClearConfirmation = false
onClear()
} label: {
ExtL10n.text("keyboard.clipboard.clear.confirm")
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.glassProminent)
.buttonBorderShape(.capsule)
.tint(palette.accent)
}
}
.padding(16)
.frame(maxWidth: 300)
.glassEffect(
.regular,
in: RoundedRectangle(cornerRadius: 18, style: .continuous)
)
.padding(.horizontal, 24)
.accessibilityElement(children: .contain)
}
}
@@ -277,23 +341,25 @@ struct KeyboardClipboardMenuButton: View, Equatable {
var body: some View {
Button(action: action) {
// Neutral chip mirrors the translation button's off state.
// SF Symbol "clipboard" sits optically low; nudge up so it centres
// in the 34pt chip the same way "xmark" does.
Image(systemName: "clipboard")
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
.foregroundStyle(palette.textSecondary)
.foregroundStyle(palette.textPrimary.opacity(0.72))
.offset(y: -0.5)
.frame(
width: KeyboardTopBarMetrics.trailingChipSize,
height: KeyboardTopBarMetrics.trailingChipSize
)
.background(buttonFill, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
// Match KeyboardCancelButton: opaque key fill + hairline, no glass.
.background(NativeKeyboardKeyColors.fill(for: colorScheme), in: Circle())
.overlay(
Circle().stroke(palette.divider, lineWidth: 0.5)
)
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.a11y"))
.accessibilityHint(ExtL10n.text("keyboard.clipboard.a11yHint"))
}
private var buttonFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
}
}
+5 -96
View File
@@ -4,8 +4,8 @@
// Typeless-inspired keyboard surface. The keyboard is laid out in three
// vertical bands, but the entire height is reserved for us we set
// `KeyboardViewController` drives height on `view` (priority 999) and mirrors
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI see presentation offset
// in `applyPresentationHeightOffset()`.
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI the input view is bottom-
// anchored so a transient over-tall system container cannot float the chrome.
//
//
// [OSG] EN header band (top)
@@ -296,6 +296,7 @@ public struct KeyboardRootView: View {
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
isEnabled: micButtonEnabled,
usesLiquidGlass: true,
onToggle: state.tapMic,
onPressingChanged: micButtonEnabled
? state.setMicTouchActive
@@ -425,6 +426,7 @@ public struct KeyboardRootView: View {
systemName: "arrow.uturn.backward",
label: ExtL10n.string("keyboard.undoA11y"),
disabled: disabled,
usesLiquidGlass: true,
hapticIntensity: state.keyboardHapticIntensity
) {
state.undoLastInsertion()
@@ -458,6 +460,7 @@ public struct KeyboardRootView: View {
}
private var shouldShowClipboardSuggestion: Bool {
guard state.canShowClipboardEntry else { return false }
guard let text = state.clipboardSuggestionText, !text.isEmpty else { return false }
return true
}
@@ -701,97 +704,3 @@ private struct TranscriptLine: View {
}
}
}
// 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")
}
}
+99 -67
View File
@@ -7,6 +7,17 @@
import SwiftUI
import OSGKeyboardShared
private struct KeyboardTabSelectionNamespaceKey: EnvironmentKey {
static let defaultValue: Namespace.ID? = nil
}
extension EnvironmentValues {
var keyboardTabSelectionNamespace: Namespace.ID? {
get { self[KeyboardTabSelectionNamespaceKey.self] }
set { self[KeyboardTabSelectionNamespaceKey.self] = newValue }
}
}
enum KeyboardTopBarMetrics {
static let height: CGFloat = 44
static let horizontalInset: CGFloat = 12
@@ -44,8 +55,8 @@ struct KeyboardBrandLogo: View {
}
struct KeyboardCancelButton: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let action: () -> Void
let accessibilityLabel: Text
@@ -61,18 +72,17 @@ struct KeyboardCancelButton: View {
width: KeyboardTopBarMetrics.trailingChipSize,
height: KeyboardTopBarMetrics.trailingChipSize
)
.background(buttonFill, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
//
.background(NativeKeyboardKeyColors.fill(for: colorScheme), in: Circle())
.overlay(
Circle().stroke(palette.divider, lineWidth: 0.5)
)
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
.accessibilityHint(accessibilityHint)
}
private var buttonFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
}
}
private enum KeyboardInputTab: CaseIterable {
@@ -83,16 +93,18 @@ private enum KeyboardInputTab: CaseIterable {
var title: String {
switch self {
case .ai: return "AI"
case .voice: return "语音"
case .chinese: return "中文"
case .english: return "EN"
case .ai: return ExtL10n.string("keyboard.tab.ai")
case .voice: return ExtL10n.string("keyboard.tab.voice")
case .chinese: return ExtL10n.string("keyboard.tab.chinese")
case .english: return ExtL10n.string("keyboard.tab.english")
}
}
}
struct KeyboardTopControls: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.keyboardTabSelectionNamespace) private var sharedSelectionNamespace
@Namespace private var fallbackSelectionNamespace
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
@@ -102,52 +114,69 @@ struct KeyboardTopControls: View {
var body: some View {
HStack(spacing: 6) {
// /
HStack(spacing: 2) {
ForEach(KeyboardInputTab.allCases, id: \.self) { tab in
Button {
select(tab)
} label: {
Text(tab.title)
.font(.system(size: 12, weight: isSelected(tab) ? .semibold : .medium))
.foregroundStyle(
isSelected(tab) ? palette.textPrimary : palette.textSecondary
)
.frame(
width: tab == .english || tab == .ai ? 34 : 42,
height: 30
)
.background {
if isSelected(tab) {
Capsule()
.fill(selectedFill)
.shadow(
color: Color.black.opacity(colorScheme == .dark ? 0.22 : 0.10),
radius: 1.5,
y: 1
)
}
}
}
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
.disabled(tab != .voice && !state.canEnterTypingSurface)
.opacity(tabOpacity(tab))
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(isSelected(tab) ? .isSelected : [])
tabButton(tab)
}
}
.padding(2)
.background(trackFill, in: Capsule())
KeyboardClipboardMenuButton(
palette: palette,
action: state.openClipboardPanel
.background(tabTrackFill, in: Capsule())
.overlay(
Capsule().stroke(palette.divider, lineWidth: 0.5)
)
.equatable()
if state.canShowClipboardEntry {
KeyboardClipboardMenuButton(
palette: palette,
action: state.openClipboardPanel
)
.equatable()
}
}
}
private var selectedFill: Color {
colorScheme == .dark ? Color(white: 0.38) : .white
private func tabButton(_ tab: KeyboardInputTab) -> some View {
let selected = isSelected(tab)
let width: CGFloat = tab == .english || tab == .ai ? 34 : 42
return Button {
withAnimation(Motion.soft) {
select(tab)
}
} label: {
tabLabel(tab, selected: selected, width: width)
}
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
.disabled(tab != .voice && !state.canEnterTypingSurface)
.opacity(tabOpacity(tab))
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(selected ? .isSelected : [])
}
@ViewBuilder
private func tabLabel(
_ tab: KeyboardInputTab,
selected: Bool,
width: CGFloat
) -> some View {
let label = Text(tab.title)
.font(.system(size: 12, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? palette.textPrimary : palette.textSecondary)
.frame(width: width, height: 30)
if selected {
let namespace = sharedSelectionNamespace ?? fallbackSelectionNamespace
//
label.background(
Capsule()
.fill(NativeKeyboardKeyColors.fill(for: colorScheme))
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
.matchedGeometryEffect(id: "keyboard-tab-selection", in: namespace)
)
} else {
label
}
}
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
@@ -158,14 +187,16 @@ struct KeyboardTopControls: View {
return 0.42
}
private var trackFill: Color {
colorScheme == .dark ? Color(white: 0.18) : Color.black.opacity(0.08)
}
private var pressedFill: Color {
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
}
/// NativeKeyboardKeyColors.fill
///
private var tabTrackFill: Color {
colorScheme == .dark ? Color(white: 0.12) : Color(white: 0.87)
}
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
switch tab {
case .ai:
@@ -211,17 +242,15 @@ struct KeyboardTopControls: View {
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
switch tab {
case .ai: return "切换到 AI 问答"
case .voice: return "切换到语音输入"
case .chinese: return "切换到中文输入"
case .english: return "切换到英文输入"
case .ai: return ExtL10n.string("keyboard.tab.ai.a11y")
case .voice: return ExtL10n.string("keyboard.tab.voice.a11y")
case .chinese: return ExtL10n.string("keyboard.tab.chinese.a11y")
case .english: return ExtL10n.string("keyboard.tab.english.a11y")
}
}
}
struct KeyboardTranslationMenuButton: View, Equatable {
@Environment(\.colorScheme) private var colorScheme
let palette: ThemePalette
let targetLocaleId: String
let onSelect: (String) -> Void
@@ -251,22 +280,25 @@ struct KeyboardTranslationMenuButton: View, Equatable {
}
}
} label: {
// Match the adjacent undo key: 44×44 rounded-rect chrome, not a circle chip.
NativeKeyboardKeySurface(
isPressed: false,
fill: NativeKeyboardKeyColors.fill(for: colorScheme),
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
border: palette.divider,
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
) {
// Match the adjacent undo key: 44×44 rounded Liquid Glass control.
ZStack {
Color.clear
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(
isEnabled
? palette.accent
: NativeKeyboardKeyColors.text(for: colorScheme)
: palette.textSecondary
)
}
.contentShape(Rectangle())
.glassEffect(
.regular.interactive(),
in: RoundedRectangle(
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius,
style: .continuous
)
)
}
.menuStyle(.button)
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
+15 -6
View File
@@ -45,14 +45,19 @@ struct LastInputEditView: View {
.frame(height: KeyboardChromeLayout.totalHeight)
.environment(\.themePalette, palette)
.onChange(of: state.editSession) { _, newValue in
guard newValue.review != nil else {
guard let review = newValue.review else {
selectedPage = 0
return
}
if reduceMotion {
selectedPage = 1
} else {
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.86)) {
// Set page before the pager remounts (see `.id` on `pages`) so the
// fresh ScrollView opens oninstead of flipping the dots
// while still showing.
selectedPage = 1
if !reduceMotion {
// Re-assert after layout; spring is only for subsequent swipes.
Task { @MainActor in
await Task.yield()
guard state.editSession.review?.utteranceID == review.utteranceID else { return }
selectedPage = 1
}
}
@@ -83,6 +88,8 @@ struct LastInputEditView: View {
contentBottomInset: 30,
selectedPage: $selectedPage
)
// Remount when review text arrives so scrollPosition can open on page 1.
.id(state.editSession.review?.utteranceID.uuidString ?? "edit-source")
}
}
@@ -113,7 +120,7 @@ struct LastInputEditView: View {
helperText(leftHelper)
Button(action: primaryAction) {
ZStack {
Capsule().fill(palette.accent)
Color.clear
if case .listening = state.editSession {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
@@ -126,6 +133,8 @@ struct LastInputEditView: View {
width: Layout.primaryButtonWidth,
height: Layout.primaryButtonHeight
)
//
.background(palette.accent, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -47,15 +47,11 @@ struct NativeKeyboardKeySurface<Content: View>: View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(isPressed ? pressedFill : fill)
)
// + 0.5pt
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(border, lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(isPressed ? 0.04 : 0.13),
radius: isPressed ? 0.5 : 1,
y: isPressed ? 0 : 1
)
.scaleEffect(isPressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: isPressed)
}
+58 -56
View File
@@ -189,39 +189,6 @@ struct RepeatingDeleteButton: View {
}
}
// MARK: - Press-down typing key
/// Fires on touch-down (not release) so click sound / haptic match the stock
/// keyboard and the voice toolbars RectangularToolbarButton.
struct PressDownKeyButton<Label: View>: View {
var disabled: Bool = false
let action: () -> Void
@ViewBuilder let label: (_ isPressed: Bool) -> Label
@State private var isPressing = false
var body: some View {
label(isPressing)
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityAddTraits(.isButton)
}
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
action()
}
.onEnded { _ in
isPressing = false
}
}
}
// MARK: - Rectangular toolbar button
struct RectangularToolbarButton: View {
@@ -233,6 +200,7 @@ struct RectangularToolbarButton: View {
let label: String
let disabled: Bool
let isSend: Bool
let usesLiquidGlass: Bool
/// Settings General Haptics; space / return use `.action` role.
var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
@@ -241,6 +209,7 @@ struct RectangularToolbarButton: View {
systemName: String,
label: String,
disabled: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
@@ -250,6 +219,7 @@ struct RectangularToolbarButton: View {
self.label = label
self.disabled = disabled
self.isSend = false
self.usesLiquidGlass = usesLiquidGlass
self.hapticIntensity = hapticIntensity
self.action = action
}
@@ -259,6 +229,7 @@ struct RectangularToolbarButton: View {
label: String,
disabled: Bool = false,
isSend: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
@@ -267,6 +238,7 @@ struct RectangularToolbarButton: View {
self.label = label
self.disabled = disabled
self.isSend = isSend
self.usesLiquidGlass = usesLiquidGlass
self.hapticIntensity = hapticIntensity
self.action = action
self.title = title
@@ -276,6 +248,7 @@ struct RectangularToolbarButton: View {
spaceStyle: Bool,
label: String,
disabled: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
@@ -285,6 +258,7 @@ struct RectangularToolbarButton: View {
self.label = label
self.disabled = disabled
self.isSend = false
self.usesLiquidGlass = usesLiquidGlass
self.hapticIntensity = hapticIntensity
self.action = action
}
@@ -292,31 +266,59 @@ struct RectangularToolbarButton: View {
@State private var isPressing = false
var body: some View {
ToolbarKeySurface(
isPressed: isPressing,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: isSend ? .send : .standard
) {
if spaceStyle {
Capsule()
.fill(buttonForeground)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(buttonForeground)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(buttonForeground)
buttonSurface
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
}
@ViewBuilder
private var buttonSurface: some View {
if usesLiquidGlass {
ZStack {
Color.clear
buttonContent
}
.glassEffect(
.regular.interactive(),
in: RoundedRectangle(
cornerRadius: ToolbarButtonMetrics.cornerRadius,
style: .continuous
)
)
// The custom press gesture fires on touch-down; mirror that state
// visually while Liquid Glass supplies its native light response.
.scaleEffect(isPressing ? 0.97 : 1)
.animation(.easeOut(duration: 0.08), value: isPressing)
} else {
ToolbarKeySurface(
isPressed: isPressing,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: isSend ? .send : .standard
) {
buttonContent
}
}
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
}
@ViewBuilder
private var buttonContent: some View {
if spaceStyle {
Capsule()
.fill(buttonForeground)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(buttonForeground)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(buttonForeground)
}
}
private var buttonForeground: Color {