chore(typing): snapshot local pinyin WIP before syncing cloud branch

Preserve the in-progress local typing/pinyin implementation so feat/pinyin
can safely reset to origin/feat/pinyin (cloud English + Chinese typing).
This commit is contained in:
Rocky
2026-08-03 13:14:01 +08:00
parent c037be3654
commit d1e5fed964
58 changed files with 370539 additions and 121 deletions
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "OSGLogoWide.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,7 @@
<svg width="952" height="291" viewBox="36 367 952 291" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z" fill="white"/>
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+86 -4
View File
@@ -16,9 +16,10 @@
import UIKit
import SwiftUI
import Combine
import OSGKeyboardShared
private final class KeyboardHostingController: UIHostingController<KeyboardRootView> {
private final class KeyboardHostingController: UIHostingController<KeyboardSurfaceRoot> {
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
[.left, .right]
}
@@ -35,11 +36,13 @@ public final class KeyboardViewController: UIInputViewController {
public typealias State = KeyboardState
private let state = State()
private let typingSession = TypingSessionController()
private let persistor = AppGroupPersistor()
private var hosting: UIHostingController<KeyboardRootView>?
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
private var keyboardHeightConstraint: NSLayoutConstraint?
private var systemEncapsulatedHeight: CGFloat = 228
private var cancellables = Set<AnyCancellable>()
private var textInserter: KeyboardTextInserter!
private var flowCoordinator: KeyboardFlowCoordinator!
@@ -47,7 +50,7 @@ public final class KeyboardViewController: UIInputViewController {
private var cursorDrag: CursorDragController!
private var targetKeyboardHeight: CGFloat {
KeyboardRootView.totalHeight
KeyboardSurfaceRoot.height(for: state.surface)
}
// MARK: - Lifecycle
@@ -67,6 +70,7 @@ public final class KeyboardViewController: UIInputViewController {
configureDictationBehavior()
installServices()
installStateActions()
installSurfaceObservers()
installSwiftUI()
_ = configSync.loadPersistedConfig()
configSync.installDarwinObservers()
@@ -76,6 +80,9 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
flowCoordinator.stopSessionMonitor()
if state.surface == .typing {
typingSession.leaveTypingMode()
}
if flowCoordinator.preservesLifecycleOnDisappear {
return
}
@@ -93,6 +100,10 @@ public final class KeyboardViewController: UIInputViewController {
flowCoordinator.startSessionMonitor()
configSync.syncOnboardingStateFromAppGroup()
configSync.refreshConfigFromAppGroup()
applyPreferredSurfaceOnOpen()
if state.surface == .typing {
typingSession.enterTypingMode()
}
configSync.autoAdvancePastKeyboardSetupStepIfNeeded()
}
@@ -124,6 +135,12 @@ public final class KeyboardViewController: UIInputViewController {
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
flowCoordinator.cancelPipelineUnlessAwaitingResult()
if state.surface == .typing {
typingSession.leaveTypingMode()
applySurface(.voice)
} else {
typingSession.leaveTypingMode()
}
}
public override func viewDidLayoutSubviews() {
@@ -200,6 +217,62 @@ public final class KeyboardViewController: UIInputViewController {
state.setCursorDragActive = { [weak self] active in
self?.cursorDrag.setCursorDragActive(active)
}
state.setSurface = { [weak self] surface in
self?.applySurface(surface)
}
}
private func installSurfaceObservers() {
state.$phase
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
if self.state.locksTypingSurface, self.state.surface == .typing {
self.applySurface(.voice)
}
}
.store(in: &cancellables)
state.$surface
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.refreshKeyboardHeight()
}
.store(in: &cancellables)
}
private func applySurface(_ surface: State.Surface) {
if surface == .typing, state.locksTypingSurface {
return
}
guard state.surface != surface else {
refreshKeyboardHeight()
return
}
state.surface = surface
if surface == .voice {
typingSession.leaveTypingMode()
}
refreshKeyboardHeight()
}
private func applyPreferredSurfaceOnOpen() {
let preferredSurface: State.Surface = TypingInputConfiguration.prefersTypingOnOpen()
? .typing
: .voice
applySurface(preferredSurface)
}
private func refreshKeyboardHeight() {
// `applyPresentationHeightOffset()` is only a one-time presentation
// primer used before `viewDidAppear`. Reusing it after a surface
// switch subtracts the system's ~228 pt encapsulated height from the
// requested typing height and collapses the keyboard to a thin strip.
// Once presented, update our height constraint directly, matching the
// final assignment in `viewDidAppear`.
keyboardHeightConstraint?.constant = targetKeyboardHeight
view.setNeedsLayout()
view.layoutIfNeeded()
}
private func refreshReturnKeyRole() {
@@ -283,7 +356,16 @@ public final class KeyboardViewController: UIInputViewController {
}
private func installSwiftUI() {
let root = KeyboardRootView(state: state)
let root = KeyboardSurfaceRoot(
state: state,
typing: typingSession,
onInsert: { [weak self] text in
self?.textDocumentProxy.insertText(text)
},
onDeleteBackward: { [weak self] in
self?.textDocumentProxy.deleteBackward()
}
)
let host = KeyboardHostingController(rootView: root)
host.view.backgroundColor = .clear
host.view.translatesAutoresizingMaskIntoConstraints = false
@@ -0,0 +1,51 @@
// KeyboardSurfaceRoot.swift
// OSGKeyboard · Keyboard Extension
//
// Switches between voice and typing surfaces driven by KeyboardState.surface.
import SwiftUI
import OSGKeyboardShared
struct KeyboardSurfaceRoot: View {
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
var onInsert: (String) -> Void
var onDeleteBackward: () -> Void
static var voiceHeight: CGFloat { KeyboardRootView.totalHeight }
static var typingHeight: CGFloat { TypingRootView.totalHeight }
static func height(for surface: KeyboardState.Surface) -> CGFloat {
switch surface {
case .voice: return voiceHeight
case .typing: return typingHeight
}
}
var body: some View {
Group {
switch state.surface {
case .voice:
KeyboardRootView(
state: state,
typing: typing,
onInsert: onInsert
)
case .typing:
TypingRootView(
state: state,
typing: typing,
onInsert: onInsert,
onDeleteBackward: onDeleteBackward
)
}
}
.animation(.easeInOut(duration: 0.15), value: state.surface)
.onChange(of: state.surface) { _, newSurface in
if newSurface == .voice {
typing.leaveTypingMode()
}
}
}
}
+343
View File
@@ -0,0 +1,343 @@
// TypingRootView.swift
// OSGKeyboard · Keyboard Extension
//
// Typing surface (Phase 1): candidate bar + QWERTY / 123 / symbols.
// Top-leading control returns to the voice surface.
import SwiftUI
import OSGKeyboardShared
enum TypingLayoutMetrics {
static let outerPaddingTop: CGFloat = 4
static let outerPaddingBottom: CGFloat = 4
static let topRegionHeight: CGFloat = KeyboardTopBarMetrics.height
static let keyRowHeight: CGFloat = 50
static let keyRowSpacing: CGFloat = 7
static let keyHorizontalSpacing: CGFloat = 6
static let bottomRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
static let verticalKeySpacing: CGFloat = 8
static let secondRowInset: CGFloat = 18
static let keyCornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
/// Extends the bottom corner keys to the same outer edges as Shift / Delete.
static let bottomLeadingKeyWidth: CGFloat = 70
static let bottomTrailingKeyWidth: CGFloat = 86
/// Shared top row + three 50 pt key rows + native spacing + bottom row.
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
}
struct TypingRootView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
var onInsert: (String) -> Void
var onDeleteBackward: () -> Void
static let totalHeight: CGFloat = TypingLayoutMetrics.totalHeight
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
var body: some View {
VStack(spacing: 0) {
topRegion
.frame(height: TypingLayoutMetrics.topRegionHeight)
keyGrid
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
bottomRow
.frame(height: TypingLayoutMetrics.bottomRowHeight)
.padding(.top, TypingLayoutMetrics.keyRowSpacing)
}
.padding(.top, TypingLayoutMetrics.outerPaddingTop)
.padding(.bottom, TypingLayoutMetrics.outerPaddingBottom)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: .infinity)
.frame(height: Self.totalHeight)
.background(Color.clear)
.environment(\.themePalette, palette)
.onAppear { typing.enterTypingMode() }
}
// MARK: - Shared top region
@ViewBuilder
private var topRegion: some View {
if hasCandidateContent {
candidateBar
} else {
idleTopBar
}
}
private var hasCandidateContent: Bool {
!typing.composition.preedit.isEmpty || !typing.composition.candidates.isEmpty
}
private var idleTopBar: some View {
HStack(spacing: Spacing.xs) {
KeyboardBrandLogo(action: state.openSettings)
if let err = typing.lastError {
Text(err)
.font(.system(size: 11))
.foregroundStyle(palette.danger)
.lineLimit(1)
}
Spacer(minLength: 0)
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
// MARK: - Candidates
private var candidateBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Spacing.xs) {
if typing.composition.candidates.isEmpty {
selectedCandidateLabel(text: typing.composition.preedit)
} else {
ForEach(
Array(typing.composition.candidates.enumerated()),
id: \.element.id
) { index, candidate in
Button {
let text = typing.selectCandidate(at: index)
if !text.isEmpty { onInsert(text) }
} label: {
if index == 0 {
selectedCandidateLabel(text: candidate.text)
} else {
Text(candidate.text)
.font(.system(size: 20, weight: .regular))
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, 10)
.frame(height: 40)
}
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
}
private func selectedCandidateLabel(text: String) -> some View {
VStack(spacing: 0) {
Text(text)
.font(.system(size: 19, weight: .medium))
.lineLimit(1)
Text(typing.composition.preedit)
.font(.system(size: 9, weight: .regular, design: .monospaced))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
.foregroundStyle(keyTextColor)
.padding(.horizontal, 12)
.frame(minWidth: 56, minHeight: 40)
.background(
selectedCandidateFill,
in: RoundedRectangle(cornerRadius: 9, style: .continuous)
)
}
// MARK: - Keys
private var keyGrid: some View {
VStack(spacing: TypingLayoutMetrics.keyRowSpacing) {
ForEach(Array(typing.keyRows.enumerated()), id: \.offset) { rowIndex, row in
GeometryReader { proxy in
let inset = rowIndex == 1 ? TypingLayoutMetrics.secondRowInset : 0
let weights = row.enumerated().map {
keyWeight(label: $0.element, index: $0.offset, rowIndex: rowIndex)
}
let spacing = TypingLayoutMetrics.keyHorizontalSpacing
* CGFloat(max(0, row.count - 1))
let availableWidth = proxy.size.width - inset * 2 - spacing
let unitWidth = availableWidth / max(1, weights.reduce(0, +))
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
ForEach(Array(row.enumerated()), id: \.offset) { keyIndex, key in
keyButton(key)
.frame(width: unitWidth * weights[keyIndex])
}
}
.padding(.horizontal, inset)
}
.frame(height: TypingLayoutMetrics.keyRowHeight)
}
}
}
private func keyButton(_ label: String) -> some View {
let isSpecial = ["", "", "123", "#+=", "ABC"].contains(label)
return Button {
let result = typing.handleKey(label)
if result == "\u{8}" {
onDeleteBackward()
} else if !result.isEmpty {
onInsert(result)
}
} label: {
keyLabel(label, isSpecial: isSpecial)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.buttonStyle(nativeKeyStyle)
}
@ViewBuilder
private func keyLabel(_ label: String, isSpecial: Bool) -> some View {
switch label {
case "":
Image(systemName: typing.shiftActive || typing.capsLock ? "shift.fill" : "shift")
.font(.system(size: 19, weight: .medium))
.foregroundStyle(keyTextColor)
case "":
Image(systemName: "delete.left")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(keyTextColor)
default:
Text(label)
.font(
.system(
size: isSpecial ? 15 : 22,
weight: isSpecial ? .semibold : .regular
)
)
.foregroundStyle(keyTextColor)
}
}
private var bottomRow: some View {
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
Button {
typing.setPage(typing.page == .letters ? .numbers : .letters)
} label: {
Text(typing.page == .letters ? "123" : "ABC")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(keyTextColor)
.frame(
width: TypingLayoutMetrics.bottomLeadingKeyWidth,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.buttonStyle(nativeKeyStyle)
Button {
let text = typing.handleSpace()
if !text.isEmpty { onInsert(text) }
} label: {
Text(typing.language == .chinese ? "空格" : "space")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(keyTextColor)
.frame(maxWidth: .infinity)
.frame(height: TypingLayoutMetrics.bottomRowHeight)
}
.buttonStyle(nativeKeyStyle)
Button {
let text = typing.handleReturn()
if !text.isEmpty { onInsert(text) }
} label: {
returnKeyLabel
.foregroundStyle(returnKeyTextColor)
.frame(
width: TypingLayoutMetrics.bottomTrailingKeyWidth,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.buttonStyle(returnKeyStyle)
}
}
@ViewBuilder
private var returnKeyLabel: some View {
switch state.returnKeyRole {
case .newline:
Image(systemName: "arrow.turn.down.left")
.font(.system(size: 21, weight: .medium))
case .send:
ExtL10n.text(state.returnKeyRole.titleKey)
.font(.system(size: 15, weight: .semibold))
}
}
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
if label == "" || label == "" || label == "#+=" {
return 1.35
}
if rowIndex == 2 && index == 0 {
// Double-pinyin semicolon occupies the normal Shift footprint.
return 1.35
}
return 1
}
private var keyFill: Color {
NativeKeyboardKeyColors.fill(for: colorScheme)
}
private var keyPressedFill: Color {
NativeKeyboardKeyColors.pressedFill(for: colorScheme)
}
private var keyTextColor: Color {
NativeKeyboardKeyColors.text(for: colorScheme)
}
private var selectedCandidateFill: Color {
colorScheme == .dark ? Color(white: 0.36) : .white
}
private var nativeKeyStyle: NativeKeyboardKeyStyle {
NativeKeyboardKeyStyle(
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
)
}
private var returnKeyStyle: NativeKeyboardKeyStyle {
switch state.returnKeyRole {
case .newline:
return nativeKeyStyle
case .send:
return NativeKeyboardKeyStyle(
fill: sendKeyFill,
pressedFill: sendKeyPressedFill,
border: Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08),
cornerRadius: TypingLayoutMetrics.keyCornerRadius
)
}
}
private var returnKeyTextColor: Color {
switch state.returnKeyRole {
case .newline:
return keyTextColor
case .send:
return .white
}
}
/// The send key stays recognizable in both appearances without becoming neon.
private var sendKeyFill: Color {
NativeKeyboardKeyColors.sendFill(for: colorScheme)
}
private var sendKeyPressedFill: Color {
NativeKeyboardKeyColors.sendPressedFill(for: colorScheme)
}
}
+41 -57
View File
@@ -8,7 +8,7 @@
// in `applyPresentationHeightOffset()`.
//
//
// [polish] [] header band (top)
// [OSG] EN header band (top)
// (transcript preview)
//
// mic (centred) action cluster:
@@ -22,17 +22,14 @@ import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let micSize: CGFloat = 121
static let micToButtonGap: CGFloat = 8
static let bottomActionRowHeight: CGFloat = 48
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
static let bottomActionFixedWidth: CGFloat = 86
static let bottomActionSpacing: CGFloat = Spacing.xs
/// Gap between the top chip row and the transcript / hint line.
/// Tightened (8 4) so the "" line hugs the chip row. The
/// space reclaimed here and from `actionClusterTopGap` is added back
/// into `actionClusterBottomGap`, keeping `totalHeight` constant while
/// nudging the mic up toward the vertical centre.
/// Gap between the top control row and the transcript / hint line.
/// Four points keeps the "" line visually attached to the controls.
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
/// Outer inset for the bottom action row from screen edges (8 pt 24 pt, +200%).
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
/// Match the typing key grid's outer edge.
static let sideActionHorizontalInset: CGFloat = KeyboardChromeLayout.horizontalInset
/// iPad: cap the content column. A full-width (~1180 pt) keyboard would
/// park delete/return at the far screen edges and turn each cursor-drag
/// pad into a ~450 pt runway capping keeps the reach ergonomics of the
@@ -40,41 +37,40 @@ private enum KeyboardLayoutMetrics {
static let contentMaxWidth: CGFloat = 700
// MARK: - Content-driven keyboard height (single source of truth)
static let outerPaddingTop: CGFloat = 2
static let outerPaddingBottom: CGFloat = 1
static let topBarHeight: CGFloat = 38
static let outerPaddingTop: CGFloat = 4
static let outerPaddingBottom: CGFloat = 4
static let topBarHeight: CGFloat = KeyboardTopBarMetrics.height
static let transcriptLineHeight: CGFloat = 22
/// mic (121) + gap (8) + bottom row (48) = 177 pt
/// mic (121) + gap (8) + bottom row (50) = 179 pt
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
/// Gap between transcript line and mic. Tightened (11.2 4) to pull
/// the mic up; the reclaimed space moves to `actionClusterBottomGap`.
static let actionClusterTopGap: CGFloat = Spacing.xs / 2
/// Gap below the bottom action row.
static let actionClusterBottomGap: CGFloat = 6
/// Moves the action cluster down so its keys share the typing row's baseline.
static let actionClusterTopGap: CGFloat = Spacing.xl
/// The shared 4 pt outer padding is the complete bottom inset.
static let actionClusterBottomGap: CGFloat = 0
static var headerBandHeight: CGFloat {
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
}
/// 2 + 64 + 4 + 177 + 15.2 + 1 = 263.2 pt (unchanged; the mic cluster
/// just sits higher now that the top gaps moved to the bottom gap).
static var totalHeight: CGFloat {
outerPaddingTop
+ headerBandHeight
+ actionClusterTopGap
+ actionClusterHeight
+ actionClusterBottomGap
+ outerPaddingBottom
}
/// 4 + 70 + 24 + 179 + 0 + 4 = 281 pt, matching Chinese / English.
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
}
public struct KeyboardRootView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: State
@ObservedObject var typing: TypingSessionController
let onInsert: (String) -> Void
public init(state: KeyboardViewController.State) {
public init(
state: KeyboardViewController.State,
typing: TypingSessionController = TypingSessionController(),
onInsert: @escaping (String) -> Void = { _ in }
) {
self.state = state
self.typing = typing
self.onInsert = onInsert
}
/// Content-driven keyboard height; mirrored on `UIInputViewController.view`
@@ -136,7 +132,7 @@ public struct KeyboardRootView: View {
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
}
/// Top chip row + transcript / hint line.
/// Top brand / mode row + transcript / hint line.
private var headerBand: some View {
VStack(spacing: KeyboardLayoutMetrics.topBarToTranscriptSpacing) {
topBar
@@ -158,35 +154,18 @@ public struct KeyboardRootView: View {
private var topBar: some View {
HStack(spacing: Spacing.xs) {
if state.isLocalEngine {
LocalEngineChip()
} else {
CloudEngineChip()
}
KeyboardBrandLogo(action: state.openSettings)
// Engine controls remain available in the host app.
// App context is auto-detected on each mic press no UI.
if state.isTranslationChipVisible {
TranslationChip(
palette: palette,
targetLocaleId: state.translationTargetLocaleId,
onSelect: state.setTranslationTargetLocaleId
)
// Decouple the open picker from the keyboard's 1 Hz App
// Group poll so scrolling doesn't reset / dismiss it.
.equatable()
}
Spacer(minLength: 0)
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"))
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
}
.padding(.horizontal, Spacing.md)
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
}
// MARK: - Action cluster
@@ -273,7 +252,12 @@ public struct KeyboardRootView: View {
private func bottomReturnButton(disabled: Bool) -> some View {
let title = ExtL10n.string(state.returnKeyRole.titleKey)
return RectangularToolbarButton(title: title, label: title, disabled: disabled) {
return RectangularToolbarButton(
title: title,
label: title,
disabled: disabled,
isSend: state.returnKeyRole == .send
) {
state.insertNewline()
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
@@ -0,0 +1,241 @@
// KeyboardTopControls.swift
// OSGKeyboard · Keyboard Extension
//
// Shared top-right input switcher used by both voice and typing surfaces.
// The same footprint is replaced by candidates while Chinese is composing.
import SwiftUI
import OSGKeyboardShared
enum KeyboardTopBarMetrics {
static let height: CGFloat = 44
static let horizontalInset: CGFloat = 12
/// TypingRootView already contributes 8 pt around the entire key surface.
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
static let logoHeight: CGFloat = 22
static let logoWidth: CGFloat = logoHeight * 952 / 291
}
struct KeyboardBrandLogo: View {
@Environment(\.colorScheme) private var colorScheme
let action: () -> Void
var body: some View {
Button(action: action) {
Image("OSGLogoWide")
.resizable()
.renderingMode(.template)
.scaledToFit()
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
.frame(
width: KeyboardTopBarMetrics.logoWidth,
height: KeyboardTopBarMetrics.logoHeight
)
.contentShape(Rectangle())
.accessibilityHidden(true)
}
.buttonStyle(BrandLogoPressStyle())
.accessibilityLabel(ExtL10n.text("keyboard.onboarding.api.openHostApp"))
}
}
private enum KeyboardInputTab: CaseIterable {
case voice
case chinese
case english
var title: String {
switch self {
case .voice: return "语音"
case .chinese: return "中文"
case .english: return "EN"
}
}
}
struct KeyboardTopControls: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
let palette: ThemePalette
let onInsert: (String) -> Void
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 ? 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(tab != .voice && !state.canEnterTypingSurface ? 0.42 : 1)
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(isSelected(tab) ? .isSelected : [])
}
}
.padding(2)
.background(trackFill, in: Capsule())
KeyboardTranslationMenuButton(
palette: palette,
targetLocaleId: state.translationTargetLocaleId,
onSelect: state.setTranslationTargetLocaleId
)
// Decouple the open picker from the keyboard's 1 Hz App Group
// poll so scrolling does not reset or dismiss the menu.
.equatable()
}
}
private var selectedFill: Color {
colorScheme == .dark ? Color(white: 0.38) : .white
}
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)
}
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
switch tab {
case .voice:
return state.surface == .voice
case .chinese:
return state.surface == .typing && typing.language == .chinese
case .english:
return state.surface == .typing && typing.language == .english
}
}
private func select(_ tab: KeyboardInputTab) {
switch tab {
case .voice:
state.setSurface(.voice)
case .chinese:
let raw = typing.setLanguage(.chinese)
if !raw.isEmpty { onInsert(raw) }
state.setSurface(.typing)
case .english:
let raw = typing.setLanguage(.english)
if !raw.isEmpty { onInsert(raw) }
state.setSurface(.typing)
}
}
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
switch tab {
case .voice: return "切换到语音输入"
case .chinese: return "切换到中文输入"
case .english: return "切换到英文输入"
}
}
}
private struct KeyboardTranslationMenuButton: View, Equatable {
@Environment(\.colorScheme) private var colorScheme
let palette: ThemePalette
let targetLocaleId: String
let onSelect: (String) -> Void
nonisolated static func == (
lhs: KeyboardTranslationMenuButton,
rhs: KeyboardTranslationMenuButton
) -> Bool {
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
}
private var isEnabled: Bool {
targetLocaleId != TranslationLanguageCatalog.offLocaleId
}
var body: some View {
Menu {
ForEach(TranslationLanguageCatalog.all) { language in
Button {
onSelect(language.id)
} label: {
if language.id == targetLocaleId {
Label(displayLabel(for: language), systemImage: "checkmark")
} else {
Text(displayLabel(for: language))
}
}
}
} label: {
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(isEnabled ? palette.accent : palette.textSecondary)
.frame(width: 34, height: 34)
.background(buttonFill, in: Circle())
.overlay(Circle().stroke(buttonStroke, lineWidth: 0.5))
}
.menuStyle(.button)
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
}
private var buttonFill: Color {
if isEnabled {
return palette.accent.opacity(colorScheme == .dark ? 0.28 : 0.16)
}
return colorScheme == .dark ? Color(white: 0.30) : .white
}
private var buttonStroke: Color {
isEnabled ? palette.accent.opacity(0.35) : palette.divider
}
private func displayLabel(for language: TranslationLanguage) -> String {
if language.id == TranslationLanguageCatalog.offLocaleId {
return SharedL10n.string("keyboard.translation.offMenu")
}
return language.nativeName
}
}
private struct BrandLogoPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.62 : 1)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
}
}
private struct TopControlPressStyle: ButtonStyle {
let pressedFill: Color
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(configuration.isPressed ? pressedFill.opacity(0.55) : .clear)
.clipShape(Capsule())
.scaleEffect(configuration.isPressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
}
}
@@ -0,0 +1,81 @@
// NativeKeyboardKeyStyle.swift
// OSGKeyboard · Keyboard Extension
//
// Shared native-like key surface used by voice and typing action rows.
import SwiftUI
enum NativeKeyboardKeyColors {
static func fill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? Color(white: 0.32) : .white
}
static func pressedFill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? Color(white: 0.23) : Color(white: 0.84)
}
static func text(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? .white : Color(red: 0.06, green: 0.06, blue: 0.08)
}
/// Adaptive brand green: brighter in dark mode and deeper in light mode.
static func sendFill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark
? Color(red: 0.286, green: 0.725, blue: 0.416)
: Color(red: 0.196, green: 0.549, blue: 0.298)
}
static func sendPressedFill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark
? Color(red: 0.227, green: 0.627, blue: 0.353)
: Color(red: 0.157, green: 0.447, blue: 0.247)
}
}
struct NativeKeyboardKeySurface<Content: View>: View {
let isPressed: Bool
let fill: Color
let pressedFill: Color
let border: Color
let cornerRadius: CGFloat
@ViewBuilder let content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(isPressed ? pressedFill : fill)
)
.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)
}
}
struct NativeKeyboardKeyStyle: ButtonStyle {
let fill: Color
let pressedFill: Color
let border: Color
let cornerRadius: CGFloat
func makeBody(configuration: Configuration) -> some View {
NativeKeyboardKeySurface(
isPressed: configuration.isPressed,
fill: fill,
pressedFill: pressedFill,
border: border,
cornerRadius: cornerRadius
) {
configuration.label
}
}
}
+70 -31
View File
@@ -11,10 +11,13 @@ import OSGKeyboardShared
private enum ToolbarButtonMetrics {
static let iconSize: CGFloat = 14
static let titleSize: CGFloat = 16
static let cornerRadius: CGFloat = 12
static let cornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
static let spaceBarCapsuleWidth: CGFloat = 31
static let pressScale: CGFloat = 0.94
static let pressOverlayOpacity: CGFloat = 0.18
}
private enum ToolbarKeyEmphasis {
case standard
case send
}
// MARK: - Press styling
@@ -25,31 +28,45 @@ private struct ToolbarKeySurface<Content: View>: View {
let isPressed: Bool
let cornerRadius: CGFloat
let emphasis: ToolbarKeyEmphasis
@ViewBuilder let content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(buttonFill, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
}
.overlay {
if isPressed {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity))
}
}
.scaleEffect(isPressed ? ToolbarButtonMetrics.pressScale : 1)
.animation(.easeOut(duration: 0.1), value: isPressed)
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: buttonFill,
pressedFill: buttonPressedFill,
border: buttonBorder,
cornerRadius: cornerRadius,
content: content
)
}
private var buttonFill: Color {
let base = colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
return isPressed ? base.opacity(0.82) : base
switch emphasis {
case .standard:
return NativeKeyboardKeyColors.fill(for: colorScheme)
case .send:
return NativeKeyboardKeyColors.sendFill(for: colorScheme)
}
}
private var buttonPressedFill: Color {
switch emphasis {
case .standard:
return NativeKeyboardKeyColors.pressedFill(for: colorScheme)
case .send:
return NativeKeyboardKeyColors.sendPressedFill(for: colorScheme)
}
}
private var buttonBorder: Color {
switch emphasis {
case .standard:
return palette.divider
case .send:
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
}
}
}
@@ -57,7 +74,7 @@ private struct ToolbarKeySurface<Content: View>: View {
/// Tap deletes once; hold repeats with tiered acceleration after 5 s.
struct RepeatingDeleteButton: View {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let disabled: Bool
let action: () -> Void
@@ -73,10 +90,14 @@ struct RepeatingDeleteButton: View {
private let accelTier4: TimeInterval = 0.015
var body: some View {
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
ToolbarKeySurface(
isPressed: isPressing,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: .standard
) {
Image(systemName: "delete.left")
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
}
.contentShape(Rectangle())
.gesture(pressGesture)
@@ -135,13 +156,14 @@ struct RepeatingDeleteButton: View {
// MARK: - Rectangular toolbar button
struct RectangularToolbarButton: View {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let systemName: String?
let spaceStyle: Bool
let title: String?
let label: String
let disabled: Bool
let isSend: Bool
let action: () -> Void
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
@@ -150,14 +172,22 @@ struct RectangularToolbarButton: View {
self.title = nil
self.label = label
self.disabled = disabled
self.isSend = false
self.action = action
}
init(title: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
init(
title: String,
label: String,
disabled: Bool = false,
isSend: Bool = false,
action: @escaping () -> Void
) {
self.systemName = nil
self.spaceStyle = false
self.label = label
self.disabled = disabled
self.isSend = isSend
self.action = action
self.title = title
}
@@ -168,25 +198,30 @@ struct RectangularToolbarButton: View {
self.title = nil
self.label = label
self.disabled = disabled
self.isSend = false
self.action = action
}
@State private var isPressing = false
var body: some View {
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
ToolbarKeySurface(
isPressed: isPressing,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: isSend ? .send : .standard
) {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.fill(buttonForeground)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.foregroundStyle(buttonForeground)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.foregroundStyle(buttonForeground)
}
}
.contentShape(Rectangle())
@@ -197,6 +232,10 @@ struct RectangularToolbarButton: View {
.accessibilityAddTraits(.isButton)
}
private var buttonForeground: Color {
isSend ? .white : NativeKeyboardKeyColors.text(for: colorScheme)
}
// Button
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)