feat: keyboard bottom-row layout, handedness preference, and screen wake lock
Rework the action cluster to a mic-above-bottom-row layout, add left/right handedness setting that swaps delete and return, and keep the screen awake during Flow recording sessions.
This commit is contained in:
@@ -115,6 +115,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
if isPendingFlowStart || isFlowRecording || isAwaitingFlowResult || awaitingDictationResult {
|
||||
return
|
||||
}
|
||||
ExtensionScreenWakeLock.releaseAll()
|
||||
cancelPipeline()
|
||||
}
|
||||
|
||||
@@ -396,6 +397,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
isFlowRecording = false
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
FlowSessionBridge.setRecordingState(.stopped)
|
||||
state.phase = .processing
|
||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||
@@ -412,6 +414,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
isFlowRecording = true
|
||||
state.lastTranscript = ""
|
||||
state.phase = .recording
|
||||
ExtensionScreenWakeLock.acquire(from: view)
|
||||
startUtteranceCountdown()
|
||||
startFlowLevelWatchdog()
|
||||
debug("startFlowRecording")
|
||||
@@ -574,6 +577,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
if isFlowRecording || isPendingFlowStart {
|
||||
if isFlowRecording {
|
||||
FlowSessionBridge.setRecordingState(.aborted)
|
||||
ExtensionScreenWakeLock.release()
|
||||
}
|
||||
isFlowRecording = false
|
||||
isPendingFlowStart = false
|
||||
|
||||
@@ -41,6 +41,7 @@ public struct AppGroupPersistor {
|
||||
// the keyboard stays open.
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
state.polishScenarioId = store.polishScenarioId
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
|
||||
// into the State flags so downstream consumers see the same
|
||||
@@ -99,6 +100,7 @@ public struct AppGroupPersistor {
|
||||
if !shouldProtectScenario {
|
||||
state.polishScenarioId = store.polishScenarioId
|
||||
}
|
||||
state.handednessPreference = store.handednessPreference
|
||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
|
||||
// toggles here so the keyboard UI doesn't flicker if the host
|
||||
// app briefly clears them while refactoring.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// ExtensionScreenWakeLock.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Keyboard extensions cannot call `UIApplication.shared`; walk the
|
||||
// responder chain to reach the host app's `UIApplication` instead.
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
enum ExtensionScreenWakeLock {
|
||||
private static var holdCount = 0
|
||||
private static weak var capturedApplication: UIApplication?
|
||||
|
||||
static func acquire(from responder: UIResponder) {
|
||||
holdCount += 1
|
||||
if holdCount == 1 {
|
||||
capturedApplication = findApplication(from: responder)
|
||||
capturedApplication?.isIdleTimerDisabled = true
|
||||
}
|
||||
}
|
||||
|
||||
static func release() {
|
||||
guard holdCount > 0 else { return }
|
||||
holdCount -= 1
|
||||
if holdCount == 0 {
|
||||
capturedApplication?.isIdleTimerDisabled = false
|
||||
capturedApplication = nil
|
||||
}
|
||||
}
|
||||
|
||||
static func releaseAll() {
|
||||
holdCount = 0
|
||||
capturedApplication?.isIdleTimerDisabled = false
|
||||
capturedApplication = nil
|
||||
}
|
||||
|
||||
private static func findApplication(from responder: UIResponder) -> UIApplication? {
|
||||
var current: UIResponder? = responder
|
||||
while let node = current {
|
||||
if let application = node as? UIApplication { return application }
|
||||
current = node.next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
// │ [polish] [中] ⚙ │ ← header band (top)
|
||||
// │ (transcript preview) │
|
||||
// │ ┊ │
|
||||
// │ (⌫) ◯ mic (↩) │ ← action cluster:
|
||||
// │ (space) │ centred below header
|
||||
// │ ◯ mic (centred) │ ← action cluster:
|
||||
// │ [delete] [ space ] [return] │ mic + bottom row
|
||||
// │ ┊ │
|
||||
// └───────────────────────────────────────────┘
|
||||
|
||||
@@ -20,36 +20,39 @@ import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
private enum KeyboardLayoutMetrics {
|
||||
static let sideActionButtonSize: CGFloat = 53
|
||||
static let sideActionIconSize: CGFloat = 19
|
||||
static let sideSpaceBarWidth: CGFloat = 19
|
||||
static let micFlankMinSpacing: CGFloat = 36
|
||||
static let sideActionStackSpacing: CGFloat = 16
|
||||
static let micSize: CGFloat = 121
|
||||
static let micToButtonGap: CGFloat = 8
|
||||
static let bottomActionRowHeight: CGFloat = 48
|
||||
static let bottomActionFixedWidth: CGFloat = 86
|
||||
static let bottomActionSpacing: CGFloat = Spacing.xs
|
||||
/// Gap between the top chip row and the transcript / hint line (4 pt → 8 pt, +100%).
|
||||
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs
|
||||
/// Outer inset for delete / return·space from screen edges (8 pt → 24 pt, +200%).
|
||||
/// Outer inset for the bottom action row from screen edges (8 pt → 24 pt, +200%).
|
||||
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
|
||||
|
||||
// MARK: - Content-driven keyboard height (single source of truth)
|
||||
static let outerPaddingTop: CGFloat = 2
|
||||
static let outerPaddingBottom: CGFloat = 6
|
||||
static let outerPaddingBottom: CGFloat = 1
|
||||
static let topBarHeight: CGFloat = 38
|
||||
static let transcriptLineHeight: CGFloat = 22
|
||||
static let actionClusterHeight: CGFloat = 132
|
||||
/// Fixed breathing room above/below the mic row (not flexible Spacers).
|
||||
static let actionClusterVerticalGap: CGFloat = Spacing.md
|
||||
/// mic (121) + gap (8) + bottom row (48) = 177 pt
|
||||
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
|
||||
/// Gap between transcript line and mic (−30% from former 16 pt).
|
||||
static let actionClusterTopGap: CGFloat = Spacing.md * 0.7
|
||||
/// Minimal gap below the bottom action row.
|
||||
static let actionClusterBottomGap: CGFloat = Spacing.xs / 2
|
||||
|
||||
static var headerBandHeight: CGFloat {
|
||||
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
|
||||
}
|
||||
|
||||
/// 2 + 68 + 16 + 132 + 16 + 6 = 240 pt
|
||||
/// 2 + 68 + 11.2 + 177 + 4 + 1 = 263.2 pt
|
||||
static var totalHeight: CGFloat {
|
||||
outerPaddingTop
|
||||
+ headerBandHeight
|
||||
+ actionClusterVerticalGap
|
||||
+ actionClusterTopGap
|
||||
+ actionClusterHeight
|
||||
+ actionClusterVerticalGap
|
||||
+ actionClusterBottomGap
|
||||
+ outerPaddingBottom
|
||||
}
|
||||
}
|
||||
@@ -76,13 +79,13 @@ public struct KeyboardRootView: View {
|
||||
headerBand
|
||||
|
||||
Color.clear
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterTopGap)
|
||||
|
||||
micActionRow
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
|
||||
|
||||
Color.clear
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
|
||||
}
|
||||
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
|
||||
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
|
||||
@@ -152,33 +155,29 @@ public struct KeyboardRootView: View {
|
||||
|
||||
// MARK: - Action cluster
|
||||
|
||||
/// Delete (left), mic (centre), return + space stacked on the right.
|
||||
/// Fixed vertical gaps in `body` keep the cluster centred without
|
||||
/// flexible Spacers consuming extra keyboard height.
|
||||
/// Mic centred above a bottom row: delete · space · return (or swapped).
|
||||
private var micActionRow: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
CircularToolbarButton(systemName: "delete.left", label: "delete") {
|
||||
state.deleteBackward()
|
||||
}
|
||||
|
||||
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
|
||||
let editingBlocked = voiceInputBlocksEditing
|
||||
let swapKeys = state.handednessPreference.swapsActionKeys
|
||||
|
||||
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
|
||||
RecordButton(
|
||||
phase: buttonPhase,
|
||||
level: state.level,
|
||||
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
||||
onToggle: state.tapMic
|
||||
)
|
||||
.frame(width: 132, height: 132)
|
||||
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
|
||||
|
||||
Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing)
|
||||
|
||||
VStack(spacing: KeyboardLayoutMetrics.sideActionStackSpacing) {
|
||||
CircularToolbarButton(systemName: "return", label: "newline") {
|
||||
state.insertNewline()
|
||||
}
|
||||
CircularToolbarButton(spaceStyle: true, label: "space") {
|
||||
state.insertSpace()
|
||||
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
|
||||
if swapKeys {
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
} else {
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,6 +185,43 @@ public struct KeyboardRootView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func bottomDeleteButton(disabled: Bool) -> some View {
|
||||
RepeatingDeleteButton(disabled: disabled) {
|
||||
state.deleteBackward()
|
||||
}
|
||||
.frame(
|
||||
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
|
||||
height: KeyboardLayoutMetrics.bottomActionRowHeight
|
||||
)
|
||||
}
|
||||
|
||||
private func bottomSpaceButton(disabled: Bool) -> some View {
|
||||
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
|
||||
state.insertSpace()
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
}
|
||||
|
||||
private func bottomReturnButton(disabled: Bool) -> some View {
|
||||
RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) {
|
||||
state.insertNewline()
|
||||
}
|
||||
.frame(
|
||||
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
|
||||
height: KeyboardLayoutMetrics.bottomActionRowHeight
|
||||
)
|
||||
}
|
||||
|
||||
/// Option C: block typing keys during the full voice-input pipeline.
|
||||
private var voiceInputBlocksEditing: Bool {
|
||||
switch state.phase {
|
||||
case .requestingPermissions, .recording, .processing:
|
||||
return true
|
||||
case .idle, .error, .denied:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonPhase: RecordButton.Phase {
|
||||
switch state.phase {
|
||||
case .idle: return .idle
|
||||
@@ -337,59 +373,6 @@ private struct TranscriptLine: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Circular toolbar button
|
||||
|
||||
private struct CircularToolbarButton: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let systemName: String?
|
||||
let spaceStyle: Bool
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
|
||||
init(systemName: String, label: String, action: @escaping () -> Void) {
|
||||
self.systemName = systemName
|
||||
self.spaceStyle = false
|
||||
self.label = label
|
||||
self.action = action
|
||||
}
|
||||
|
||||
init(spaceStyle: Bool, label: String, action: @escaping () -> Void) {
|
||||
self.systemName = nil
|
||||
self.spaceStyle = spaceStyle
|
||||
self.label = label
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Group {
|
||||
if spaceStyle {
|
||||
Capsule()
|
||||
.fill(palette.textPrimary)
|
||||
.frame(width: KeyboardLayoutMetrics.sideSpaceBarWidth, height: 3)
|
||||
} else if let systemName {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: KeyboardLayoutMetrics.sideActionIconSize, weight: .medium))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
.frame(width: KeyboardLayoutMetrics.sideActionButtonSize, height: KeyboardLayoutMetrics.sideActionButtonSize)
|
||||
.background(sideButtonFill, in: Circle())
|
||||
.overlay(Circle().stroke(palette.dividerStrong, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(label))
|
||||
}
|
||||
|
||||
private var sideButtonFill: Color {
|
||||
colorScheme == .dark
|
||||
? Color(red: 0.20, green: 0.20, blue: 0.22)
|
||||
: palette.surfaceElevated
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cloud engine chip (cloud always ASR + LLM polish)
|
||||
|
||||
private struct CloudEngineChip: View {
|
||||
|
||||
@@ -42,13 +42,13 @@ struct RecordButton: View {
|
||||
return remainingSeconds <= 10
|
||||
}
|
||||
|
||||
/// Decorative rings are sized to stay inside the 132 pt frame applied
|
||||
/// Decorative rings are sized to stay inside the 121 pt frame applied
|
||||
/// by `KeyboardRootView` so glow / breath animations are not clipped.
|
||||
private enum Layout {
|
||||
static let disc: CGFloat = 104
|
||||
static let outerRing: CGFloat = 112
|
||||
static let breathRing: CGFloat = 108
|
||||
static let glow: CGFloat = 128
|
||||
static let disc: CGFloat = 95
|
||||
static let outerRing: CGFloat = 106
|
||||
static let breathRing: CGFloat = 100
|
||||
static let glow: CGFloat = 119
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -65,8 +65,8 @@ struct RecordButton: View {
|
||||
RadialGradient(
|
||||
colors: [palette.recordRed.opacity(0.55), .clear],
|
||||
center: .center,
|
||||
startRadius: 50,
|
||||
endRadius: 100
|
||||
startRadius: 46,
|
||||
endRadius: 92
|
||||
)
|
||||
)
|
||||
.frame(width: Layout.glow, height: Layout.glow)
|
||||
@@ -93,10 +93,10 @@ struct RecordButton: View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.system(size: 38, weight: .medium))
|
||||
.font(.system(size: 36, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
case .recording:
|
||||
VStack(spacing: 4) {
|
||||
VStack(spacing: 3) {
|
||||
if let remainingSeconds {
|
||||
Text(formatRemaining(remainingSeconds))
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
@@ -109,7 +109,7 @@ struct RecordButton: View {
|
||||
color: Color(red: 1.0, green: 0.78, blue: 0.78),
|
||||
active: true
|
||||
)
|
||||
.frame(width: 72, height: 32)
|
||||
.frame(width: 73, height: 32)
|
||||
}
|
||||
.transition(.opacity)
|
||||
case .processing:
|
||||
@@ -119,7 +119,7 @@ struct RecordButton: View {
|
||||
.scaleEffect(2.5)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 30, weight: .medium))
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
.foregroundStyle(palette.warning)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// ToolbarActionButtons.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Bottom-row action keys: repeating delete, space, and return.
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
// MARK: - Layout metrics
|
||||
|
||||
private enum ToolbarButtonMetrics {
|
||||
static let iconSize: CGFloat = 14
|
||||
static let cornerRadius: CGFloat = 12
|
||||
static let spaceBarCapsuleWidth: CGFloat = 31
|
||||
static let pressScale: CGFloat = 0.94
|
||||
static let pressOverlayOpacity: CGFloat = 0.18
|
||||
}
|
||||
|
||||
// MARK: - Haptics
|
||||
|
||||
private enum ToolbarHaptics {
|
||||
@MainActor
|
||||
static func tap() {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Press styling
|
||||
|
||||
private struct ToolbarKeyPressStyle: ButtonStyle {
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.overlay {
|
||||
if configuration.isPressed {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity))
|
||||
}
|
||||
}
|
||||
.scaleEffect(configuration.isPressed ? ToolbarButtonMetrics.pressScale : 1)
|
||||
.animation(.easeOut(duration: 0.1), value: configuration.isPressed)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: configuration.isPressed) { _, pressed in
|
||||
pressed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
ToolbarHaptics.tap()
|
||||
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 {
|
||||
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 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.label = label
|
||||
self.disabled = disabled
|
||||
self.action = action
|
||||
}
|
||||
|
||||
init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) {
|
||||
self.systemName = nil
|
||||
self.spaceStyle = spaceStyle
|
||||
self.label = label
|
||||
self.disabled = disabled
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Group {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(keyBackground)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(ToolbarKeyPressStyle(cornerRadius: ToolbarButtonMetrics.cornerRadius))
|
||||
.disabled(disabled)
|
||||
.opacity(disabled ? 0.38 : 1)
|
||||
.accessibilityLabel(Text(label))
|
||||
}
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
private var keyBackground: some View {
|
||||
let fill = colorScheme == .dark
|
||||
? Color(red: 0.20, green: 0.20, blue: 0.22)
|
||||
: palette.surfaceElevated
|
||||
return RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous)
|
||||
.fill(fill)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user