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:
@@ -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 |
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user