fix(ipad): ship iPad P0 layout/globe fixes, edit-last-input, drop clipboard commands

Adapt typing/voice surfaces for iPad width and height, add the system globe
key and last-input editing flow, harden host-only Rime deployment, and remove
clipboard voice commands. Bump build to 61.
This commit is contained in:
Rocky
2026-08-10 13:50:50 +08:00
parent 53abad2050
commit 2bc8c1b87d
85 changed files with 5703 additions and 3997 deletions
@@ -0,0 +1,89 @@
// EditTextPager.swift
// OSGKeyboard · Shared
import SwiftUI
public struct EditTextPager: View {
@Environment(\.themePalette) private var palette
private let originalTitle: String
private let originalText: String
private let editedTitle: String
private let editedText: String?
private let contentBottomInset: CGFloat
@Binding private var selectedPage: Int?
public init(
originalTitle: String,
originalText: String,
editedTitle: String,
editedText: String?,
contentBottomInset: CGFloat = 0,
selectedPage: Binding<Int?>
) {
self.originalTitle = originalTitle
self.originalText = originalText
self.editedTitle = editedTitle
self.editedText = editedText
self.contentBottomInset = contentBottomInset
self._selectedPage = selectedPage
}
public var body: some View {
GeometryReader { proxy in
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
textPage(title: originalTitle, text: originalText)
.frame(
width: proxy.size.width,
height: proxy.size.height,
alignment: .topLeading
)
.id(0)
if let editedText {
textPage(title: editedTitle, text: editedText)
.frame(
width: proxy.size.width,
height: proxy.size.height,
alignment: .topLeading
)
.id(1)
}
}
.frame(height: proxy.size.height, alignment: .top)
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollTargetBehavior(.paging)
.scrollPosition(id: $selectedPage)
.clipped()
.accessibilityIdentifier("edit.textPager")
}
}
private func textPage(title: String, text: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
ScrollView(.vertical) {
Text(text)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.disabled)
}
}
.padding(.horizontal, Spacing.md)
.padding(.bottom, contentBottomInset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// A fully clear expanded frame is not a reliable UIKit ScrollView hit
// target inside keyboard extensions. This imperceptible rendered layer
// makes the complete page participate in native pan hit testing.
.background(Color.black.opacity(0.001))
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title)\(text)")
}
}
@@ -14,7 +14,7 @@ public struct RecordButton: View {
case idleReady
/// Orange voice input unavailable (missing key, session not ready, etc.).
case idleUnavailable
/// Clipboard intent is acquiring material or warming the host; tap cancels.
/// Host audio is starting; tap handling is owned by the coordinator.
case preparing
case recording
case processing
@@ -25,11 +25,11 @@ public struct RecordButton: View {
public let level: Double
public let remainingSeconds: Int?
public let isEnabled: Bool
/// When true (and `phase == .recording`), use blue clipboard-command chrome.
public let isClipboardCommandRecording: Bool
public let onToggle: () -> Void
/// When non-nil, a 0.45s hold starts clipboard-command recording (tap again to stop).
public let onClipboardLongPressBegan: (() -> Void)?
public let onPressingChanged: (Bool) -> Void
/// When non-nil, a 0.45s hold starts explicit editing of the last insertion.
public let onEditLongPressBegan: (() -> Void)?
public static let longPressDuration: TimeInterval = 0.45
@State private var breath = false
/// Set once a press has been consumed as a hold, so its release is not
@@ -42,17 +42,17 @@ public struct RecordButton: View {
level: Double,
remainingSeconds: Int? = nil,
isEnabled: Bool = true,
isClipboardCommandRecording: Bool = false,
onToggle: @escaping () -> Void,
onClipboardLongPressBegan: (() -> Void)? = nil
onPressingChanged: @escaping (Bool) -> Void = { _ in },
onEditLongPressBegan: (() -> Void)? = nil
) {
self.phase = phase
self.level = level
self.remainingSeconds = remainingSeconds
self.isEnabled = isEnabled
self.isClipboardCommandRecording = isClipboardCommandRecording
self.onToggle = onToggle
self.onClipboardLongPressBegan = onClipboardLongPressBegan
self.onPressingChanged = onPressingChanged
self.onEditLongPressBegan = onEditLongPressBegan
}
private var isUrgent: Bool {
@@ -60,15 +60,12 @@ public struct RecordButton: View {
return remainingSeconds <= 10
}
/// Active recording tint: blue for clipboard-command, red for dictation.
private var recordingTint: Color {
isClipboardCommandRecording ? palette.recordBlue : palette.recordRed
palette.recordRed
}
private var waveformColor: Color {
isClipboardCommandRecording
? Color(red: 0.78, green: 0.88, blue: 1.0)
: Color(red: 1.0, green: 0.78, blue: 0.78)
Color(red: 1.0, green: 0.78, blue: 0.78)
}
private enum Layout {
@@ -86,7 +83,6 @@ public struct RecordButton: View {
.scaleEffect(breath ? 1.18 : 0.95)
.opacity(phase == .recording ? 1 : 0)
.animation(Motion.breath, value: breath)
.animation(colorTransition, value: isClipboardCommandRecording)
Circle()
.fill(
@@ -102,7 +98,6 @@ public struct RecordButton: View {
.blur(radius: 18)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: level)
.animation(colorTransition, value: isClipboardCommandRecording)
Circle()
.stroke(Color.white.opacity(isIdle ? 0.08 : 0.12), lineWidth: 0.5)
@@ -156,17 +151,17 @@ public struct RecordButton: View {
.frame(width: Layout.disc, height: Layout.disc)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: remainingSeconds)
.animation(colorTransition, value: isClipboardCommandRecording)
}
.contentShape(Circle())
.modifier(
RecordButtonPressModifier(
phase: phase,
isEnabled: isEnabled,
supportsClipboardLongPress: onClipboardLongPressBegan != nil,
supportsEditLongPress: onEditLongPressBegan != nil,
longPressArmed: $longPressArmed,
onToggle: onToggle,
onClipboardLongPressBegan: onClipboardLongPressBegan
onPressingChanged: onPressingChanged,
onEditLongPressBegan: onEditLongPressBegan
)
)
.onAppear { breath = (phase == .recording) }
@@ -176,11 +171,6 @@ public struct RecordButton: View {
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
}
/// Red blue mode switch (~0.25s).
private var colorTransition: Animation {
.easeInOut(duration: 0.25)
}
private var isIdle: Bool {
switch phase {
case .idleReady, .idleUnavailable:
@@ -230,32 +220,43 @@ public struct RecordButton: View {
private struct RecordButtonPressModifier: ViewModifier {
let phase: RecordButton.Phase
let isEnabled: Bool
let supportsClipboardLongPress: Bool
let supportsEditLongPress: Bool
@Binding var longPressArmed: Bool
let onToggle: () -> Void
let onClipboardLongPressBegan: (() -> Void)?
let onPressingChanged: (Bool) -> Void
let onEditLongPressBegan: (() -> Void)?
/// A single recognizer serves every phase. Branching on `phase` here would
/// rebuild the gesture mid-press clipboard long-press flips the phase while
/// rebuild the gesture mid-press edit long-press flips the phase while
/// the finger is still down and SwiftUI hands that in-flight touch to the
/// fresh recognizer, letting one press both open and close a round.
func body(content: Content) -> some View {
content.onLongPressGesture(
minimumDuration: ClipboardMaterialFilter.longPressDuration,
minimumDuration: RecordButton.longPressDuration,
maximumDistance: 120,
pressing: { pressing in
if pressing {
longPressArmed = false
onPressingChanged(true)
return
}
// A press already consumed as a hold must not replay as a tap.
if longPressArmed {
longPressArmed = false
onPressingChanged(false)
return
}
handleTap()
onPressingChanged(false)
},
perform: { longPressArmed = handleHold() }
perform: {
let action = holdAction()
// Arm before dispatch: dispatching switches to LastInputEditView
// synchronously. Arming afterwards lets the disappearing
// RecordButton replay this same finger-up as a tap/stop.
longPressArmed = RecordButtonGesturePolicy.consumesPress(action)
dispatch(action)
}
)
}
@@ -263,15 +264,12 @@ private struct RecordButtonPressModifier: ViewModifier {
dispatch(RecordButtonGesturePolicy.tapAction(phase: phase, isEnabled: isEnabled))
}
/// Returns whether the hold consumed the press.
private func handleHold() -> Bool {
let action = RecordButtonGesturePolicy.holdAction(
private func holdAction() -> RecordButtonGestureAction {
RecordButtonGesturePolicy.holdAction(
phase: phase,
isEnabled: isEnabled,
supportsClipboardLongPress: supportsClipboardLongPress
supportsEditLongPress: supportsEditLongPress
)
dispatch(action)
return RecordButtonGesturePolicy.consumesPress(action)
}
private func dispatch(_ action: RecordButtonGestureAction) {
@@ -280,8 +278,8 @@ private struct RecordButtonPressModifier: ViewModifier {
break
case .toggle:
onToggle()
case .beginClipboardCommand:
onClipboardLongPressBegan?()
case .beginEditLastInput:
onEditLongPressBegan?()
}
}
}
@@ -3,17 +3,17 @@
//
// Pure tap / hold routing for the mic button. Kept out of the view so the
// "one press produces at most one action" invariant is unit-testable: the
// clipboard long-press flips the phase while the finger is still down, and
// edit long-press flips the phase while the finger is still down, and
// regressions there let a single press both open and close a round.
import Foundation
public enum RecordButtonGestureAction: Equatable, Sendable {
case none
/// Start dictation, cancel a preparing clipboard intent, or stop recording
/// Start dictation, cancel a preparing edit, or stop recording
/// all of which the coordinator resolves from its own phase.
case toggle
case beginClipboardCommand
case beginEditLastInput
}
public enum RecordButtonGesturePolicy {
@@ -38,13 +38,13 @@ public enum RecordButtonGesturePolicy {
public static func holdAction(
phase: RecordButton.Phase,
isEnabled: Bool,
supportsClipboardLongPress: Bool
supportsEditLongPress: Bool
) -> RecordButtonGestureAction {
switch phase {
case .idleReady, .idleUnavailable, .error:
guard supportsClipboardLongPress else { return .none }
guard supportsEditLongPress else { return .none }
guard isEnabled || phase == .idleUnavailable else { return .none }
return .beginClipboardCommand
return .beginEditLastInput
case .recording:
return isEnabled ? .toggle : .none
case .preparing, .processing:
+1 -1
View File
@@ -39,7 +39,7 @@ public struct ThemePalette: Sendable, Equatable {
public let dividerStrong: Color
public let recordRed: Color
/// Clipboard-command hold-to-talk recording (distinct from dictation red).
/// Alternate recording accent available to platform-specific surfaces.
public let recordBlue: Color
}