feat: cursor navigation, key sounds, dictionary tooling, key security
Batch of in-progress app work from the working tree. - feat(keyboard): CursorNavigation + CursorDragPad for caret movement; KeyboardSoundFeedback for system key click sounds - feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet; TranscriptPostProcessor quality gate; retire DictionaryLearner - feat(ui): TabBarVisibility handling; drop PageHeaderRow / PageHeaderConfirmButton; refresh views and localizable strings - fix(security): move the hardcoded DeepSeek key out of PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift (seeded from .example by generate-xcodeproj.sh) - docs(agents): add Conventional Commits versioning + bilingual changelog rules - chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache Custom language model / lexicon work stays on feature/custom-language-model-asr. Changelog bullets added under [Unreleased]; no version bump.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
// CursorDragPad.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// SwiftUI layout wrapper for a UIKit pan recognizer. SwiftUI gestures
|
||||
// can be unreliable in keyboard-extension hosting views; keeping the
|
||||
// recognizer in UIKit preserves the existing layout while avoiding that
|
||||
// failure mode.
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os
|
||||
|
||||
private let cursorDragLog = Logger(subsystem: "com.osgkeyboard.ios", category: "CursorDrag")
|
||||
|
||||
struct CursorDragPad: UIViewRepresentable {
|
||||
let enabled: Bool
|
||||
let onPressingChanged: (Bool) -> Void
|
||||
let moveHorizontal: (Int) -> Void
|
||||
let moveVertical: (Int) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> CursorDragPadUIView {
|
||||
cursorDragLog.info("makeUIView (enabled=\(enabled))")
|
||||
let view = CursorDragPadUIView()
|
||||
view.coordinator = context.coordinator
|
||||
view.isPadEnabled = enabled
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: CursorDragPadUIView, context: Context) {
|
||||
context.coordinator.onPressingChanged = onPressingChanged
|
||||
context.coordinator.moveHorizontal = moveHorizontal
|
||||
context.coordinator.moveVertical = moveVertical
|
||||
uiView.isPadEnabled = enabled
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(
|
||||
onPressingChanged: onPressingChanged,
|
||||
moveHorizontal: moveHorizontal,
|
||||
moveVertical: moveVertical
|
||||
)
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var onPressingChanged: (Bool) -> Void
|
||||
var moveHorizontal: (Int) -> Void
|
||||
var moveVertical: (Int) -> Void
|
||||
|
||||
init(
|
||||
onPressingChanged: @escaping (Bool) -> Void,
|
||||
moveHorizontal: @escaping (Int) -> Void,
|
||||
moveVertical: @escaping (Int) -> Void
|
||||
) {
|
||||
self.onPressingChanged = onPressingChanged
|
||||
self.moveHorizontal = moveHorizontal
|
||||
self.moveVertical = moveVertical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class CursorDragPadUIView: UIView, UIGestureRecognizerDelegate {
|
||||
weak var coordinator: CursorDragPad.Coordinator?
|
||||
|
||||
var isPadEnabled = true {
|
||||
didSet {
|
||||
isUserInteractionEnabled = isPadEnabled
|
||||
applyIdleTint()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pad tint
|
||||
// MUST stay non-zero. When embedded via `UIViewRepresentable`, a fully
|
||||
// transparent (alpha 0) background makes SwiftUI's host treat the region
|
||||
// as empty pass-through space and the pad stops receiving touches. A tiny
|
||||
// alpha (just above UIKit's 0.01 hit-test threshold) keeps the pad fully
|
||||
// draggable while remaining imperceptible.
|
||||
//
|
||||
// The keyboard surface itself is transparent (system chrome shows
|
||||
// through), so there is no fixed colour to match; `systemGray4` tracks
|
||||
// the system keyboard's grey in both light and dark and, at ~2% alpha,
|
||||
// blends invisibly. `withAlphaComponent` on a dynamic colour can freeze
|
||||
// the current trait, so resolve per-trait to stay appearance-adaptive.
|
||||
private static let padTint = UIColor { traits in
|
||||
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
|
||||
}
|
||||
private static var idleTint: UIColor { padTint }
|
||||
private static var activeTint: UIColor { padTint }
|
||||
|
||||
private func applyIdleTint() {
|
||||
backgroundColor = isPadEnabled ? Self.idleTint : .clear
|
||||
}
|
||||
|
||||
private var lastTranslation = CGPoint.zero
|
||||
private var horizontalCarry: CGFloat = 0
|
||||
private var verticalCarry: CGFloat = 0
|
||||
private var didFireBeginHaptic = false
|
||||
/// Once the finger clears the dead zone, lock to one axis so slight
|
||||
/// diagonal jitter does not flip between horizontal and vertical steps.
|
||||
private var lockedAxis: LockedAxis?
|
||||
|
||||
private enum LockedAxis {
|
||||
case horizontal
|
||||
case vertical
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = Self.idleTint
|
||||
isMultipleTouchEnabled = false
|
||||
isUserInteractionEnabled = true
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||||
pan.delegate = self
|
||||
pan.minimumNumberOfTouches = 1
|
||||
pan.maximumNumberOfTouches = 1
|
||||
pan.cancelsTouchesInView = false
|
||||
pan.delaysTouchesBegan = false
|
||||
pan.delaysTouchesEnded = false
|
||||
addGestureRecognizer(pan)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
let size = "\(Int(bounds.width))x\(Int(bounds.height))"
|
||||
cursorDragLog.info("didMoveToWindow size=\(size, privacy: .public) attached=\(self.window != nil)")
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
let hit = super.hitTest(point, with: event)
|
||||
if hit === self {
|
||||
cursorDragLog.debug("hitTest inside pad")
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
// Raw touch delivery drives the "drag mode" state so a static hold
|
||||
// (which a pan recognizer ignores until the finger moves) already
|
||||
// switches the keyboard into cursor-drag chrome.
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
guard isPadEnabled else { return }
|
||||
backgroundColor = Self.activeTint
|
||||
coordinator?.onPressingChanged(true)
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesEnded(touches, with: event)
|
||||
applyIdleTint()
|
||||
coordinator?.onPressingChanged(false)
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesCancelled(touches, with: event)
|
||||
applyIdleTint()
|
||||
coordinator?.onPressingChanged(false)
|
||||
}
|
||||
|
||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
guard isPadEnabled, let coordinator else { return }
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
resetGestureState()
|
||||
backgroundColor = Self.activeTint
|
||||
coordinator.onPressingChanged(true)
|
||||
cursorDragLog.info("pan began")
|
||||
case .changed:
|
||||
handlePanChanged(gesture, coordinator: coordinator)
|
||||
case .ended, .cancelled, .failed:
|
||||
resetGestureState()
|
||||
applyIdleTint()
|
||||
coordinator.onPressingChanged(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePanChanged(
|
||||
_ gesture: UIPanGestureRecognizer,
|
||||
coordinator: CursorDragPad.Coordinator
|
||||
) {
|
||||
let translation = gesture.translation(in: self)
|
||||
let delta = CGPoint(
|
||||
x: translation.x - lastTranslation.x,
|
||||
y: translation.y - lastTranslation.y
|
||||
)
|
||||
lastTranslation = translation
|
||||
|
||||
let deadZone: CGFloat = 6
|
||||
guard max(abs(translation.x), abs(translation.y)) > deadZone else { return }
|
||||
|
||||
if !didFireBeginHaptic {
|
||||
didFireBeginHaptic = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
|
||||
if lockedAxis == nil {
|
||||
lockedAxis = abs(translation.x) >= abs(translation.y) ? .horizontal : .vertical
|
||||
}
|
||||
|
||||
switch lockedAxis {
|
||||
case .horizontal:
|
||||
horizontalCarry += delta.x
|
||||
let threshold = stepThreshold(for: translation.x)
|
||||
let steps = consumeCarry(&horizontalCarry, threshold: threshold)
|
||||
if steps != 0 {
|
||||
coordinator.moveHorizontal(steps)
|
||||
}
|
||||
case .vertical:
|
||||
verticalCarry += delta.y
|
||||
let threshold = stepThreshold(for: translation.y) * Self.verticalSensitivityDamping
|
||||
let steps = consumeCarry(&verticalCarry, threshold: threshold)
|
||||
if steps != 0 {
|
||||
coordinator.moveVertical(steps)
|
||||
}
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func resetGestureState() {
|
||||
lastTranslation = .zero
|
||||
horizontalCarry = 0
|
||||
verticalCarry = 0
|
||||
didFireBeginHaptic = false
|
||||
lockedAxis = nil
|
||||
}
|
||||
|
||||
/// Vertical steps move in large character chunks, so require more finger
|
||||
/// travel per step than horizontal to keep them from firing too fast.
|
||||
/// Higher = less sensitive.
|
||||
private static let verticalSensitivityDamping: CGFloat = 2.6
|
||||
|
||||
/// Farther drag means a smaller threshold and faster stepping,
|
||||
/// capped so long swipes remain controllable.
|
||||
private func stepThreshold(for totalAxisDistance: CGFloat) -> CGFloat {
|
||||
let deadZone: CGFloat = 6
|
||||
let accelerated = max(0, abs(totalAxisDistance) - deadZone)
|
||||
let progress = min(1, accelerated / 100)
|
||||
return 12 - progress * 7
|
||||
}
|
||||
|
||||
private func consumeCarry(_ carry: inout CGFloat, threshold: CGFloat) -> Int {
|
||||
guard threshold > 0 else { return 0 }
|
||||
let steps = Int(carry / threshold)
|
||||
if steps != 0 {
|
||||
carry -= CGFloat(steps) * threshold
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,12 @@ private enum KeyboardLayoutMetrics {
|
||||
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
|
||||
/// 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.
|
||||
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
|
||||
|
||||
@@ -37,16 +41,18 @@ private enum KeyboardLayoutMetrics {
|
||||
static let transcriptLineHeight: CGFloat = 22
|
||||
/// 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
|
||||
/// 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
|
||||
|
||||
static var headerBandHeight: CGFloat {
|
||||
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
|
||||
}
|
||||
|
||||
/// 2 + 68 + 11.2 + 177 + 4 + 1 = 263.2 pt
|
||||
/// 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
|
||||
@@ -70,6 +76,17 @@ public struct KeyboardRootView: View {
|
||||
/// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`).
|
||||
static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight
|
||||
|
||||
// MARK: - Cursor-drag pad geometry
|
||||
|
||||
/// Mic disc side length.
|
||||
static let micSize: CGFloat = KeyboardLayoutMetrics.micSize
|
||||
/// Vertical offset from the keyboard's top edge to the mic disc.
|
||||
static let micTopOffset: CGFloat = KeyboardLayoutMetrics.outerPaddingTop
|
||||
+ KeyboardLayoutMetrics.headerBandHeight
|
||||
+ KeyboardLayoutMetrics.actionClusterTopGap
|
||||
/// Horizontal inset the side pads should respect.
|
||||
static let sideInset: CGFloat = KeyboardLayoutMetrics.sideActionHorizontalInset
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
}
|
||||
@@ -109,6 +126,7 @@ public struct KeyboardRootView: View {
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: state.hasCompletedOnboarding)
|
||||
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
|
||||
}
|
||||
|
||||
/// Top chip row + transcript / hint line.
|
||||
@@ -121,9 +139,12 @@ public struct KeyboardRootView: View {
|
||||
phase: state.phase,
|
||||
transcript: state.lastTranscript,
|
||||
flowSessionActive: state.flowSessionActive,
|
||||
micDisabled: state.micDisabled,
|
||||
micDisabledHint: state.micDisabledHint,
|
||||
isLocalEngine: state.isLocalEngine,
|
||||
localModelsReady: state.localModelsReady,
|
||||
localModelsLoaded: state.localModelsLoaded,
|
||||
cursorDragHintActive: state.cursorDragActive,
|
||||
openSettings: state.openSettings,
|
||||
startFlowSession: state.startFlowSession
|
||||
)
|
||||
@@ -142,7 +163,14 @@ public struct KeyboardRootView: View {
|
||||
}
|
||||
// App context is auto-detected on each mic press — no UI.
|
||||
if state.isTranslationChipVisible {
|
||||
TranslationChip(state: state)
|
||||
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) {
|
||||
@@ -162,18 +190,37 @@ public struct KeyboardRootView: View {
|
||||
// MARK: - Action cluster
|
||||
|
||||
/// Mic centred above a bottom row: delete · space · return (or swapped).
|
||||
/// The side cursor-drag pads are SwiftUI layout wrappers around UIKit
|
||||
/// pan recognizers, avoiding SwiftUI gesture delivery issues in
|
||||
/// keyboard extensions.
|
||||
private var micActionRow: some View {
|
||||
let editingBlocked = voiceInputBlocksEditing
|
||||
let swapKeys = state.handednessPreference.swapsActionKeys
|
||||
let micDisabled = state.micDisabled
|
||||
let cursorPadsEnabled = state.cursorDragNavigationEnabled && !editingBlocked
|
||||
|
||||
// Dragging hides the mic + bottom keys (kept in the layout via
|
||||
// opacity so the pads' hit area never shifts mid-gesture) and lets
|
||||
// the cursor-drag chrome take over.
|
||||
let dragging = state.cursorDragActive
|
||||
|
||||
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
|
||||
RecordButton(
|
||||
phase: buttonPhase,
|
||||
level: state.level,
|
||||
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
||||
onToggle: state.tapMic
|
||||
)
|
||||
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
|
||||
HStack(spacing: 0) {
|
||||
cursorDragPad(enabled: cursorPadsEnabled)
|
||||
|
||||
RecordButton(
|
||||
phase: buttonPhase,
|
||||
level: state.level,
|
||||
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
||||
isEnabled: !micDisabled,
|
||||
onToggle: state.tapMic
|
||||
)
|
||||
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
|
||||
.opacity(dragging ? 0 : 1)
|
||||
|
||||
cursorDragPad(enabled: cursorPadsEnabled)
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.micSize)
|
||||
|
||||
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
|
||||
if swapKeys {
|
||||
@@ -186,11 +233,23 @@ public struct KeyboardRootView: View {
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
}
|
||||
}
|
||||
.opacity(dragging ? 0 : 1)
|
||||
}
|
||||
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func cursorDragPad(enabled: Bool) -> some View {
|
||||
CursorDragPad(
|
||||
enabled: enabled,
|
||||
onPressingChanged: state.setCursorDragActive,
|
||||
moveHorizontal: state.moveCursorHorizontal,
|
||||
moveVertical: state.moveCursorVertical
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private func bottomDeleteButton(disabled: Bool) -> some View {
|
||||
RepeatingDeleteButton(disabled: disabled) {
|
||||
state.deleteBackward()
|
||||
@@ -276,17 +335,39 @@ private struct TranscriptLine: View {
|
||||
let phase: KeyboardViewController.State.Phase
|
||||
let transcript: String
|
||||
let flowSessionActive: Bool
|
||||
let micDisabled: Bool
|
||||
let micDisabledHint: String
|
||||
let isLocalEngine: Bool
|
||||
let localModelsReady: Bool
|
||||
let localModelsLoaded: Bool
|
||||
let cursorDragHintActive: Bool
|
||||
let openSettings: () -> Void
|
||||
let startFlowSession: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch phase {
|
||||
case .idle:
|
||||
if isLocalEngine, !localModelsReady {
|
||||
// While dragging the caret, the whole mic cluster + transcript
|
||||
// line give way to the cursor-drag overlay, so hide this line's
|
||||
// "点按说话" / status text entirely.
|
||||
if !cursorDragHintActive {
|
||||
phaseContent
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var phaseContent: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
if micDisabled {
|
||||
Text(micDisabledHint)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else if isLocalEngine, !localModelsReady {
|
||||
Button(action: openSettings) {
|
||||
HStack(spacing: 4) {
|
||||
Text(ExtL10n.string("keyboard.models.notDownloaded"))
|
||||
@@ -335,14 +416,11 @@ private struct TranscriptLine: View {
|
||||
.truncationMode(.head)
|
||||
.frame(maxWidth: .infinity)
|
||||
case .processing:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.accent)
|
||||
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .error(_, let msg):
|
||||
Text(msg ?? "")
|
||||
.font(TypeStyle.caption)
|
||||
@@ -365,10 +443,7 @@ private struct TranscriptLine: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
|
||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
||||
|
||||
@@ -21,6 +21,7 @@ struct RecordButton: View {
|
||||
let level: Double // 0...1
|
||||
/// Seconds left in the current utterance; shown only while recording.
|
||||
let remainingSeconds: Int?
|
||||
let isEnabled: Bool
|
||||
let onToggle: () -> Void
|
||||
|
||||
@State private var breath: Bool = false
|
||||
@@ -29,11 +30,13 @@ struct RecordButton: View {
|
||||
phase: Phase,
|
||||
level: Double,
|
||||
remainingSeconds: Int? = nil,
|
||||
isEnabled: Bool = true,
|
||||
onToggle: @escaping () -> Void
|
||||
) {
|
||||
self.phase = phase
|
||||
self.level = level
|
||||
self.remainingSeconds = remainingSeconds
|
||||
self.isEnabled = isEnabled
|
||||
self.onToggle = onToggle
|
||||
}
|
||||
|
||||
@@ -103,6 +106,8 @@ struct RecordButton: View {
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
// 倒计时略下移,与波形一起在圆盘内更居中。
|
||||
.offset(y: 3)
|
||||
}
|
||||
WaveformView(
|
||||
level: level,
|
||||
@@ -110,13 +115,15 @@ struct RecordButton: View {
|
||||
active: true
|
||||
)
|
||||
.frame(width: 73, height: 32)
|
||||
.opacity(0.4)
|
||||
.scaleEffect(0.96)
|
||||
}
|
||||
.transition(.opacity)
|
||||
case .processing:
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(palette.textPrimary)
|
||||
.scaleEffect(2.5)
|
||||
.scaleEffect(1.25)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
@@ -129,8 +136,9 @@ struct RecordButton: View {
|
||||
.animation(Motion.soft, value: remainingSeconds)
|
||||
}
|
||||
.contentShape(Circle())
|
||||
.opacity(isEnabled ? 1 : 0.45)
|
||||
.onTapGesture {
|
||||
guard phase != .processing else { return }
|
||||
guard isEnabled, phase != .processing else { return }
|
||||
onToggle()
|
||||
}
|
||||
.onAppear { breath = (phase == .recording) }
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
// Bottom-row action keys: repeating delete, space, and return.
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
// MARK: - Layout metrics
|
||||
@@ -17,36 +16,8 @@ private enum ToolbarButtonMetrics {
|
||||
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
|
||||
@@ -120,7 +91,7 @@ struct RepeatingDeleteButton: View {
|
||||
guard !disabled, !isPressing else { return }
|
||||
isPressing = true
|
||||
repeatStartedAt = Date()
|
||||
ToolbarHaptics.tap()
|
||||
KeyboardSoundFeedback.deleteClick()
|
||||
action()
|
||||
startRepeating()
|
||||
}
|
||||
@@ -143,6 +114,7 @@ struct RepeatingDeleteButton: View {
|
||||
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)
|
||||
@@ -186,39 +158,39 @@ struct RectangularToolbarButton: View {
|
||||
self.action = action
|
||||
}
|
||||
|
||||
@State private var isPressing = false
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
.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)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(pressGesture)
|
||||
.opacity(disabled ? 0.38 : 1)
|
||||
.allowsHitTesting(!disabled)
|
||||
.accessibilityLabel(Text(label))
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
|
||||
@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)
|
||||
// 按下即响、按下即执行,与系统键盘保持一致(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,27 @@
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct TranslationChip: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
struct TranslationChip: View, Equatable {
|
||||
/// Passed in as a value (not read from `@Environment`) so the chip can
|
||||
/// be wrapped in `.equatable()` at the call site: `EquatableView`
|
||||
/// suppresses environment-driven refreshes, so injecting the palette
|
||||
/// here keeps colours correct across dark/light switches.
|
||||
let palette: ThemePalette
|
||||
/// The active target-locale id (`offLocaleId` == translation off).
|
||||
let targetLocaleId: String
|
||||
/// Writes the picked locale id — wired to `state.setTranslationTargetLocaleId`.
|
||||
let onSelect: (String) -> Void
|
||||
|
||||
@ObservedObject var state: KeyboardViewController.State
|
||||
/// Only `palette` and `targetLocaleId` drive the visuals; the
|
||||
/// `onSelect` closure is deliberately excluded from equality. Because
|
||||
/// the keyboard polls the App Group at 1 Hz (each poll re-publishes the
|
||||
/// `KeyboardState`), the parent view re-renders every second. Without
|
||||
/// this, SwiftUI would rebuild the `Menu` on every poll — dismissing an
|
||||
/// open picker or snapping its scroll position back to the top. With
|
||||
/// `.equatable()` the picker is rebuilt only on a real state change.
|
||||
nonisolated static func == (lhs: TranslationChip, rhs: TranslationChip) -> Bool {
|
||||
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
@@ -43,9 +60,9 @@ struct TranslationChip: View {
|
||||
// derived from it.
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Button {
|
||||
state.setTranslationTargetLocaleId(language.id)
|
||||
onSelect(language.id)
|
||||
} label: {
|
||||
if language.id == currentSelectionId {
|
||||
if language.id == targetLocaleId {
|
||||
Label(displayLabel(for: language), systemImage: "checkmark")
|
||||
} else {
|
||||
Text(displayLabel(for: language))
|
||||
@@ -62,8 +79,8 @@ struct TranslationChip: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var label: some View {
|
||||
let target = TranslationLanguageCatalog.resolve(state.translationTargetLocaleId)
|
||||
let enabled = state.translationEnabled
|
||||
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||
let enabled = targetLocaleId != TranslationLanguageCatalog.offLocaleId
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
|
||||
@@ -80,12 +97,6 @@ struct TranslationChip: View {
|
||||
.overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
|
||||
}
|
||||
|
||||
/// Active selection id — the chip derives "on" from a non-off
|
||||
/// locale id, so reading `translationTargetLocaleId` is enough.
|
||||
private var currentSelectionId: String {
|
||||
state.translationTargetLocaleId
|
||||
}
|
||||
|
||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||
return ExtL10n.string("keyboard.translation.offMenu")
|
||||
|
||||
Reference in New Issue
Block a user