Files
OSGKeyboard/OSGKeyboardExt/Views/ToolbarActionButtons.swift
T
Rocky cc8dd1070a feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding
- Add macOS menu-bar dictation app with local ASR models (SenseVoice/Qwen3),
  global Option hotkey, and bottom overlay
- Add cloud ASR/LLM providers (Anthropic, Volcengine, Bailian, and more) with
  provider logos, model listing, and connection checks
- Add shared 7-day usage stats UI (UsageStatsCluster / SevenDayUsageChart)
- Add iOS onboarding step 6 for polish LLM setup; hide custom-language-model
  diagnostic toggle behind DEBUG
- Unify iOS onboarding tagline with the macOS brand line ("开口即文字。")
- Rewrite README (Chinese-first, product-oriented) and refresh GitHub Pages
2026-07-11 19:10:20 +08:00

214 lines
7.1 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
static let titleSize: CGFloat = 16
static let cornerRadius: CGFloat = 12
static let spaceBarCapsuleWidth: CGFloat = 31
static let pressScale: CGFloat = 0.94
static let pressOverlayOpacity: CGFloat = 0.18
}
// 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
@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)
}
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
}
}
// MARK: - Repeating delete
/// Tap deletes once; hold repeats with tiered acceleration after 5 s.
struct RepeatingDeleteButton: View {
@Environment(\.themePalette) private var palette
let disabled: Bool
let action: () -> Void
@State private var isPressing = false
@State private var repeatTask: Task<Void, Never>?
@State private var repeatStartedAt: Date?
private let initialDelay: TimeInterval = 0.4
private let normalInterval: TimeInterval = 0.08
private let accelTier2: TimeInterval = 0.05
private let accelTier3: TimeInterval = 0.03
private let accelTier4: TimeInterval = 0.015
var body: some View {
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
Image(systemName: "delete.left")
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text("delete"))
.accessibilityAddTraits(.isButton)
}
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
repeatStartedAt = Date()
KeyboardSoundFeedback.deleteClick()
action()
startRepeating()
}
.onEnded { _ in
stopRepeating()
}
}
private func interval(for elapsed: TimeInterval) -> TimeInterval {
if elapsed < 5 { return normalInterval }
if elapsed < 8 { return accelTier2 }
if elapsed < 12 { return accelTier3 }
return accelTier4
}
private func startRepeating() {
repeatTask?.cancel()
repeatTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(initialDelay * 1_000_000_000))
guard !Task.isCancelled, isPressing else { return }
let anchor = repeatStartedAt ?? Date()
while !Task.isCancelled, isPressing {
KeyboardSoundFeedback.deleteClick()
action()
let elapsed = Date().timeIntervalSince(anchor)
let wait = 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
}
}
// MARK: - Rectangular toolbar button
struct RectangularToolbarButton: View {
@Environment(\.themePalette) private var palette
let systemName: String?
let spaceStyle: Bool
let title: String?
let label: String
let disabled: Bool
let action: () -> Void
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = systemName
self.spaceStyle = false
self.title = nil
self.label = label
self.disabled = disabled
self.action = action
}
init(title: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = nil
self.spaceStyle = false
self.label = label
self.disabled = disabled
self.action = action
self.title = title
}
init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = nil
self.spaceStyle = spaceStyle
self.title = nil
self.label = label
self.disabled = disabled
self.action = action
}
@State private var isPressing = false
var body: some View {
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
}
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
}
// 按下即响、按下即执行,与系统键盘保持一致(Button 默认松手才触发)。
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
KeyboardSoundFeedback.keyClick()
action()
}
.onEnded { _ in
isPressing = false
}
}
}