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:
@@ -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