Files
OSGKeyboard/OSGKeyboardExt/Views/ToolbarActionButtons.swift
T
Rocky fb97ec2937 feat(keyboard): unify assistant voice and AI workflows
Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
2026-08-16 12:07:06 +08:00

381 lines
12 KiB
Swift

// ToolbarActionButtons.swift
// OSGKeyboard · Keyboard Extension
//
// Bottom-row action keys: repeating delete, space, and return.
import SwiftUI
import OSGKeyboardShared
// MARK: - Layout metrics
private enum ToolbarButtonMetrics {
static let iconSize: CGFloat = 14
/// Slightly larger glyph so a 52 pt circular glass key does not look sparse.
static let circleIconSize: CGFloat = 17
static let titleSize: CGFloat = 16
static let cornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
static let spaceBarCapsuleWidth: CGFloat = 31
}
private enum ToolbarKeyEmphasis {
case standard
case send
}
// MARK: - Press styling
private struct ToolbarKeySurface<Content: View>: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
let isPressed: Bool
let cornerRadius: CGFloat
let emphasis: ToolbarKeyEmphasis
@ViewBuilder let content: () -> Content
var body: some View {
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: buttonFill,
pressedFill: buttonPressedFill,
border: buttonBorder,
cornerRadius: cornerRadius,
content: content
)
}
private var buttonFill: Color {
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)
}
}
}
// MARK: - Repeating delete
/// Shared hold-to-repeat timing for delete keys (voice + typing).
enum RepeatingDeleteTiming {
static let initialDelay: TimeInterval = 0.4
static let normalInterval: TimeInterval = 0.08
static let accelTier2: TimeInterval = 0.05
static let accelTier3: TimeInterval = 0.03
static let accelTier4: TimeInterval = 0.015
static func interval(for elapsed: TimeInterval) -> TimeInterval {
if elapsed < 5 { return normalInterval }
if elapsed < 8 { return accelTier2 }
if elapsed < 12 { return accelTier3 }
return accelTier4
}
}
/// Tap fires once; hold repeats with tiered acceleration after 5 s.
/// Voice and typing delete keys share this press engine.
struct RepeatingPressButton<Label: View>: View {
var disabled: Bool = false
/// Plays the system delete click on each fire (matches stock keyboard).
var playsDeleteSound: Bool = true
/// Settings → General → Haptics; `.off` skips impact feedback.
var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
@ViewBuilder let label: (_ isPressed: Bool) -> Label
@State private var isPressing = false
@State private var repeatTask: Task<Void, Never>?
@State private var repeatStartedAt: Date?
var body: some View {
label(isPressing)
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text("delete"))
.accessibilityAddTraits(.isButton)
.onDisappear { stopRepeating() }
}
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
repeatStartedAt = Date()
fireOnce()
startRepeating()
}
.onEnded { _ in
stopRepeating()
}
}
private func fireOnce() {
if playsDeleteSound {
KeyboardSoundFeedback.deleteClick()
}
KeyboardHapticFeedback.play(role: .delete, intensity: hapticIntensity)
action()
}
private func startRepeating() {
repeatTask?.cancel()
repeatTask = Task { @MainActor in
try? await Task.sleep(
nanoseconds: UInt64(RepeatingDeleteTiming.initialDelay * 1_000_000_000)
)
guard !Task.isCancelled, isPressing else { return }
let anchor = repeatStartedAt ?? Date()
while !Task.isCancelled, isPressing {
fireOnce()
let elapsed = Date().timeIntervalSince(anchor)
let wait = RepeatingDeleteTiming.interval(for: elapsed)
try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
}
}
}
private func stopRepeating() {
isPressing = false
repeatStartedAt = nil
repeatTask?.cancel()
repeatTask = nil
}
}
/// Voice-toolbar delete chrome around ``RepeatingPressButton``.
struct RepeatingDeleteButton: View {
@Environment(\.colorScheme) private var colorScheme
let disabled: Bool
/// Mirrors Settings → General → Haptics (same as typing delete).
var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
var body: some View {
RepeatingPressButton(
disabled: disabled,
hapticIntensity: hapticIntensity,
action: action
) { isPressed in
ToolbarKeySurface(
isPressed: isPressed,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: .standard
) {
Image(systemName: "delete.left")
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
}
}
}
}
// MARK: - Rectangular toolbar button
struct RectangularToolbarButton: View {
@Environment(\.colorScheme) private var colorScheme
let systemName: String?
let spaceStyle: Bool
let title: String?
let label: String
let disabled: Bool
let isSend: Bool
let usesLiquidGlass: Bool
let usesCircleGlass: Bool
/// Settings → General → Haptics; space / return use `.action` role.
var hapticIntensity: KeyboardHapticIntensity = .off
var onPressingChanged: (Bool) -> Void = { _ in }
let action: () -> Void
init(
systemName: String,
label: String,
disabled: Bool = false,
usesLiquidGlass: Bool = false,
usesCircleGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
onPressingChanged: @escaping (Bool) -> Void = { _ in },
action: @escaping () -> Void
) {
self.systemName = systemName
self.spaceStyle = false
self.title = nil
self.label = label
self.disabled = disabled
self.isSend = false
self.usesLiquidGlass = usesLiquidGlass
self.usesCircleGlass = usesCircleGlass
self.hapticIntensity = hapticIntensity
self.onPressingChanged = onPressingChanged
self.action = action
}
init(
title: String,
label: String,
disabled: Bool = false,
isSend: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
onPressingChanged: @escaping (Bool) -> Void = { _ in },
action: @escaping () -> Void
) {
self.systemName = nil
self.spaceStyle = false
self.label = label
self.disabled = disabled
self.isSend = isSend
self.usesLiquidGlass = usesLiquidGlass
self.usesCircleGlass = false
self.hapticIntensity = hapticIntensity
self.onPressingChanged = onPressingChanged
self.action = action
self.title = title
}
init(
spaceStyle: Bool,
label: String,
disabled: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
onPressingChanged: @escaping (Bool) -> Void = { _ in },
action: @escaping () -> Void
) {
self.systemName = nil
self.spaceStyle = spaceStyle
self.title = nil
self.label = label
self.disabled = disabled
self.isSend = false
self.usesLiquidGlass = usesLiquidGlass
self.usesCircleGlass = false
self.hapticIntensity = hapticIntensity
self.onPressingChanged = onPressingChanged
self.action = action
}
@State private var isPressing = false
var body: some View {
buttonSurface
.contentShape(usesCircleGlass ? AnyShape(Circle()) : AnyShape(Rectangle()))
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
.onDisappear {
guard isPressing else { return }
isPressing = false
onPressingChanged(false)
}
}
@ViewBuilder
private var buttonSurface: some View {
if usesLiquidGlass {
ZStack {
Color.clear
buttonContent
}
.modifier(ToolbarLiquidGlass(isCircle: usesCircleGlass))
// The custom press gesture fires on touch-down; mirror that state
// visually while Liquid Glass supplies its native light response.
.scaleEffect(isPressing ? 0.97 : 1)
.animation(.easeOut(duration: 0.08), value: isPressing)
} else {
ToolbarKeySurface(
isPressed: isPressing,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: isSend ? .send : .standard
) {
buttonContent
}
}
}
@ViewBuilder
private var buttonContent: some View {
if spaceStyle {
Capsule()
.fill(buttonForeground)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(
size: usesCircleGlass
? ToolbarButtonMetrics.circleIconSize
: ToolbarButtonMetrics.iconSize,
weight: .semibold
))
.foregroundStyle(buttonForeground)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(buttonForeground)
}
}
private var buttonForeground: Color {
isSend ? .white : NativeKeyboardKeyColors.text(for: colorScheme)
}
// 按下即响、即震、即执行,与系统键盘 / 打字面保持一致(Button 默认松手才触发)。
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
onPressingChanged(true)
KeyboardSoundFeedback.keyClick()
KeyboardHapticFeedback.play(role: .action, intensity: hapticIntensity)
action()
}
.onEnded { _ in
isPressing = false
onPressingChanged(false)
}
}
}
private struct ToolbarLiquidGlass: ViewModifier {
let isCircle: Bool
func body(content: Content) -> some View {
if isCircle {
content.glassEffect(.regular.interactive(), in: Circle())
} else {
content.glassEffect(
.regular.interactive(),
in: RoundedRectangle(
cornerRadius: ToolbarButtonMetrics.cornerRadius,
style: .continuous
)
)
}
}
}