2bc8c1b87d
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.
727 lines
29 KiB
Swift
727 lines
29 KiB
Swift
// TypingRootView.swift
|
||
// OSGKeyboard · Keyboard Extension
|
||
//
|
||
// Typing surface: candidate bar + QWERTY / 123 / symbols.
|
||
// Top-leading control returns to the voice surface.
|
||
|
||
import SwiftUI
|
||
import OSGKeyboardShared
|
||
|
||
enum TypingLayoutMetrics {
|
||
// Size decisions live in `TypingSurfaceMetrics` (Shared) so the UIKit
|
||
// height constraint and this SwiftUI grid cannot disagree.
|
||
static let outerPaddingTop: CGFloat = TypingSurfaceMetrics.outerPaddingTop
|
||
static let outerPaddingBottom: CGFloat = TypingSurfaceMetrics.outerPaddingBottom
|
||
static let topRegionHeight: CGFloat = TypingSurfaceMetrics.topRegionHeight
|
||
static let verticalKeySpacing: CGFloat = TypingSurfaceMetrics.verticalKeySpacing
|
||
static let keyCornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
|
||
/// Shared top row + three 50 pt key rows + native spacing + bottom row.
|
||
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
|
||
/// Collapsed candidate strip: keep this small so ScrollView doesn't fight ▼.
|
||
static let collapsedBarCandidateLimit = 10
|
||
/// Trailing ▼/▲ control (visual chip + hit pad); lives in an HStack, not over text.
|
||
static let expandChevronHitWidth: CGFloat = 44
|
||
static let expandChevronVisualSize: CGFloat = 34
|
||
static let expandGridColumns = 5
|
||
static let expandCellHeight: CGFloat = 42
|
||
|
||
// MARK: - iPad (regular size class) metrics
|
||
|
||
static func metrics(isIPad: Bool, width: CGFloat) -> TypingKeyLayoutBuilder.Metrics {
|
||
TypingSurfaceMetrics.metrics(isIPad: isIPad, width: width)
|
||
}
|
||
|
||
static func contentHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
|
||
TypingSurfaceMetrics.contentHeight(isIPad: isIPad, width: width)
|
||
}
|
||
}
|
||
|
||
struct TypingRootView: View {
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
@ObservedObject var state: KeyboardState
|
||
@ObservedObject var typing: TypingSessionController
|
||
|
||
var onInsert: (String) -> Void
|
||
var onDeleteBackward: () -> Void
|
||
|
||
/// After the first expand, keep the panel tree mounted and only toggle
|
||
/// opacity so subsequent ▼/▲ taps stay cheap.
|
||
@State private var candidatePanelMounted = false
|
||
/// Key currently under the finger (grid-level touch pad).
|
||
@State private var highlightedKeyID: String?
|
||
|
||
static func totalHeight(isIPad: Bool = false, width: CGFloat = 0) -> CGFloat {
|
||
TypingLayoutMetrics.contentHeight(isIPad: isIPad, width: width)
|
||
}
|
||
|
||
private var palette: ThemePalette {
|
||
colorScheme == .dark ? Palette.dark : Palette.light
|
||
}
|
||
|
||
var body: some View {
|
||
let expanded = typing.isCandidatePanelExpanded
|
||
VStack(spacing: 0) {
|
||
topRegion
|
||
.frame(height: TypingLayoutMetrics.topRegionHeight)
|
||
|
||
// Keep the QWERTY tree mounted; only toggle visibility so ▼/▲
|
||
// does not destroy GeometryReader key rows every time.
|
||
ZStack(alignment: .top) {
|
||
typingKeySurface
|
||
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
|
||
.opacity(expanded ? 0 : 1)
|
||
.allowsHitTesting(!expanded)
|
||
.accessibilityHidden(expanded)
|
||
|
||
if expanded || candidatePanelMounted {
|
||
expandedCandidatePanel
|
||
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.opacity(expanded ? 1 : 0)
|
||
.allowsHitTesting(expanded)
|
||
.accessibilityHidden(!expanded)
|
||
.transition(.identity)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.padding(.top, TypingLayoutMetrics.outerPaddingTop)
|
||
.padding(.bottom, TypingLayoutMetrics.outerPaddingBottom)
|
||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||
// No content-width cap: a key grid has to span the host width or the
|
||
// user's muscle memory for the system keyboard's absolute key
|
||
// positions is wrong on every key.
|
||
.frame(maxWidth: .infinity)
|
||
.frame(
|
||
height: Self.totalHeight(
|
||
isIPad: state.usesIPadLayoutMetrics,
|
||
width: state.layoutWidth
|
||
)
|
||
)
|
||
.background(Color.clear)
|
||
.environment(\.themePalette, palette)
|
||
// enterTypingMode is owned by KeyboardViewController.viewWillAppear
|
||
// when surface == .typing — avoid a duplicate prepare here.
|
||
.onChange(of: typing.isCandidatePanelExpanded) { _, isExpanded in
|
||
if isExpanded { candidatePanelMounted = true }
|
||
}
|
||
.onChange(of: hasCandidateContent) { _, hasContent in
|
||
if !hasContent { candidatePanelMounted = false }
|
||
}
|
||
.onAppear {
|
||
// Primary warm-up lives in KVC.viewWillAppear (covers app switch).
|
||
// Keep this for first mount / surface flip into typing.
|
||
KeyboardHapticFeedback.prepare()
|
||
}
|
||
}
|
||
|
||
// MARK: - Shared top region
|
||
|
||
@ViewBuilder
|
||
private var topRegion: some View {
|
||
if hasCandidateContent {
|
||
candidateBar
|
||
} else {
|
||
idleTopBar
|
||
}
|
||
}
|
||
|
||
private var hasCandidateContent: Bool {
|
||
!typing.composition.preedit.isEmpty || !typing.composition.candidates.isEmpty
|
||
}
|
||
|
||
private var idleTopBar: some View {
|
||
HStack(spacing: Spacing.xs) {
|
||
KeyboardBrandLogo(action: state.openSettings)
|
||
// Globe key now lives at the bottom-left of the keyboard (matching
|
||
// iOS system layout); see the typingKeySurface ForEach.
|
||
|
||
if let err = typing.lastError {
|
||
typingErrorLabel(err)
|
||
}
|
||
|
||
// iOS-style editing cluster (undo / redo / copy / cut) — iPad only,
|
||
// where the top bar has room to mirror the system shortcut row.
|
||
if state.usesIPadLayoutMetrics {
|
||
editingToolbar
|
||
}
|
||
|
||
Spacer(minLength: 0)
|
||
|
||
KeyboardTopControls(
|
||
state: state,
|
||
typing: typing,
|
||
palette: palette,
|
||
onInsert: onInsert
|
||
)
|
||
}
|
||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||
}
|
||
|
||
/// Rime failures that only host-side deployment can fix become a tappable
|
||
/// jump into the app; everything else stays a plain read-only notice.
|
||
@ViewBuilder
|
||
private func typingErrorLabel(_ message: String) -> some View {
|
||
if typing.lastErrorNeedsHostDeployment {
|
||
Button(action: state.openInputMethodSetup) {
|
||
HStack(spacing: 2) {
|
||
Text(message)
|
||
.font(.system(size: 11))
|
||
.lineLimit(1)
|
||
Image(systemName: "arrow.up.right")
|
||
.font(.system(size: 9, weight: .semibold))
|
||
}
|
||
.foregroundStyle(palette.danger)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityHint(ExtL10n.text("keyboard.typing.setupA11yHint"))
|
||
} else {
|
||
Text(message)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(palette.danger)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
|
||
// MARK: - Candidates
|
||
|
||
private var candidateBar: some View {
|
||
// HStack (not overlay / safeAreaInset): ▼ never paints over candidate text.
|
||
// Globe key now lives at the bottom-left of the keyboard (matching
|
||
// iOS system layout); see the typingKeySurface ForEach.
|
||
HStack(spacing: 0) {
|
||
if state.usesIPadLayoutMetrics {
|
||
editingToolbar
|
||
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||
}
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: Spacing.xs) {
|
||
if typing.composition.candidates.isEmpty {
|
||
selectedCandidateLabel(text: typing.composition.preedit)
|
||
} else if typing.isCandidatePanelExpanded {
|
||
candidateChip(text: typing.composition.candidates[0].text) {
|
||
apply(typing.selectCandidate(at: 0))
|
||
}
|
||
} else {
|
||
ForEach(
|
||
Array(
|
||
typing.composition.candidates
|
||
.prefix(TypingLayoutMetrics.collapsedBarCandidateLimit)
|
||
.enumerated()
|
||
),
|
||
id: \.element.id
|
||
) { index, candidate in
|
||
if index == 0 {
|
||
candidateChip(text: candidate.text) {
|
||
apply(typing.selectCandidate(at: index))
|
||
}
|
||
} else {
|
||
Text(candidate.text)
|
||
.font(.system(size: 20, weight: .regular))
|
||
.foregroundStyle(palette.textPrimary)
|
||
.padding(.horizontal, 10)
|
||
.frame(height: 40)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
apply(typing.selectCandidate(at: index))
|
||
}
|
||
.accessibilityAddTraits(.isButton)
|
||
.accessibilityLabel(candidate.text)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||
.padding(.trailing, Spacing.xs)
|
||
}
|
||
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
|
||
|
||
if typing.canExpandCandidatePanel {
|
||
expandChevronButton
|
||
} else {
|
||
Color.clear.frame(width: KeyboardTopBarMetrics.nestedHorizontalInset)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Opaque chip like the translation control so ▼ never shares pixels with text.
|
||
private var expandChevronButton: some View {
|
||
Button {
|
||
var transaction = Transaction()
|
||
transaction.disablesAnimations = true
|
||
withTransaction(transaction) {
|
||
typing.toggleCandidatePanelExpanded()
|
||
}
|
||
} label: {
|
||
Image(systemName: typing.isCandidatePanelExpanded ? "chevron.up" : "chevron.down")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(palette.textSecondary)
|
||
.frame(
|
||
width: TypingLayoutMetrics.expandChevronVisualSize,
|
||
height: TypingLayoutMetrics.expandChevronVisualSize
|
||
)
|
||
.background(expandChevronFill, in: Circle())
|
||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||
.frame(
|
||
width: TypingLayoutMetrics.expandChevronHitWidth,
|
||
height: KeyboardTopBarMetrics.height
|
||
)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.trailing, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||
.accessibilityLabel(typing.isCandidatePanelExpanded ? "收起候选" : "更多候选")
|
||
}
|
||
|
||
private var expandChevronFill: Color {
|
||
colorScheme == .dark ? Color(white: 0.30) : .white
|
||
}
|
||
|
||
// MARK: - Editing toolbar (iPad)
|
||
|
||
/// iOS-style editing cluster: undo / redo / copy / cut. Mirrors the system
|
||
/// keyboard's shortcut row; surfaced only on iPad where the top bar fits.
|
||
@ViewBuilder
|
||
private var editingToolbar: some View {
|
||
HStack(spacing: 2) {
|
||
editingToolbarButton(
|
||
systemName: "arrow.uturn.backward",
|
||
label: ExtL10n.string("keyboard.undoA11y"),
|
||
enabled: state.undoAvailable
|
||
) { state.undoLastInsertion() }
|
||
editingToolbarButton(
|
||
systemName: "arrow.uturn.forward",
|
||
label: ExtL10n.string("keyboard.redoA11y"),
|
||
enabled: state.redoAvailable
|
||
) { state.redoLastInsertion() }
|
||
editingToolbarButton(
|
||
systemName: "doc.on.doc",
|
||
label: ExtL10n.string("keyboard.copyA11y"),
|
||
enabled: state.copyAvailable
|
||
) { state.copySelection() }
|
||
editingToolbarButton(
|
||
systemName: "scissors",
|
||
label: ExtL10n.string("keyboard.cutA11y"),
|
||
enabled: state.cutAvailable
|
||
) { state.cutSelection() }
|
||
}
|
||
}
|
||
|
||
private func editingToolbarButton(
|
||
systemName: String,
|
||
label: String,
|
||
enabled: Bool,
|
||
action: @escaping () -> Void
|
||
) -> some View {
|
||
Button(action: action) {
|
||
Image(systemName: systemName)
|
||
.font(.system(size: 15, weight: .medium))
|
||
.foregroundStyle(enabled ? palette.textSecondary : palette.textTertiary)
|
||
.frame(width: 34, height: 34)
|
||
.background(enabled ? editingToolbarButtonFill : .clear, in: Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!enabled)
|
||
.accessibilityLabel(Text(label))
|
||
}
|
||
|
||
private var editingToolbarButtonFill: Color {
|
||
colorScheme == .dark ? Color(white: 0.30) : .white
|
||
}
|
||
|
||
/// UIKit-recycled labels — no SwiftUI Button per candidate.
|
||
private var expandedCandidatePanel: some View {
|
||
CandidateExpandGridView(
|
||
candidates: typing.composition.candidates,
|
||
textColor: UIColor(keyTextColor),
|
||
dividerColor: UIColor(palette.dividerStrong),
|
||
onSelect: { index in
|
||
var transaction = Transaction()
|
||
transaction.disablesAnimations = true
|
||
withTransaction(transaction) {
|
||
apply(typing.selectCandidate(at: index))
|
||
}
|
||
}
|
||
)
|
||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||
}
|
||
|
||
private func candidateChip(text: String, action: @escaping () -> Void) -> some View {
|
||
selectedCandidateLabel(text: text)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(perform: action)
|
||
.accessibilityAddTraits(.isButton)
|
||
.accessibilityLabel(text)
|
||
}
|
||
|
||
private func selectedCandidateLabel(text: String) -> some View {
|
||
VStack(spacing: 0) {
|
||
Text(text)
|
||
.font(.system(size: 19, weight: .medium))
|
||
.lineLimit(1)
|
||
if !typing.composition.preedit.isEmpty {
|
||
Text(typing.composition.preedit)
|
||
.font(.system(size: 9, weight: .regular, design: .monospaced))
|
||
.foregroundStyle(palette.textSecondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.foregroundStyle(keyTextColor)
|
||
.padding(.horizontal, 12)
|
||
.frame(minWidth: 56, minHeight: 40)
|
||
.background(
|
||
selectedCandidateFill,
|
||
in: RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||
)
|
||
}
|
||
|
||
// MARK: - Keys
|
||
|
||
/// Visual keys + grid-level touch pad (Phase 1 gap fill + Phase 2
|
||
/// down → move → up). Geometry is shared via ``TypingKeyLayoutBuilder``.
|
||
private var typingKeySurface: some View {
|
||
GeometryReader { proxy in
|
||
let layout = makeTypingKeyLayout(size: proxy.size)
|
||
ZStack(alignment: .topLeading) {
|
||
TypingKeyTouchPad(
|
||
layout: layout,
|
||
hapticIntensity: state.keyboardHapticIntensity,
|
||
onHighlightChange: { highlightedKeyID = $0 },
|
||
onCommit: { commitTypingKey($0) },
|
||
onDeleteFire: { apply(typing.handleKey("⌫")) },
|
||
onShiftBegan: { typing.beginShiftHold() },
|
||
onShiftEnded: { typing.endShiftHold() }
|
||
)
|
||
|
||
ForEach(layout.keys) { key in
|
||
if key.id == TypingKeyLayoutBuilder.BottomKeyID.globe.rawValue {
|
||
// Globe key: SystemGlobeKey's UIButton handles its own
|
||
// tap (advance) / long-press (system input-mode list),
|
||
// so leave hit testing enabled here. The touch pad sits
|
||
// underneath but the UIButton intercepts touches in
|
||
// this frame, so hit testing against `layout.keys`
|
||
// never fires for the globe slot.
|
||
SystemGlobeKey(
|
||
state: state,
|
||
width: key.visualFrame.width,
|
||
height: key.visualFrame.height
|
||
)
|
||
.frame(width: key.visualFrame.width, height: key.visualFrame.height)
|
||
.position(
|
||
x: key.visualFrame.midX,
|
||
y: key.visualFrame.midY
|
||
)
|
||
} else {
|
||
visualTypingKey(key)
|
||
.frame(width: key.visualFrame.width, height: key.visualFrame.height)
|
||
.position(
|
||
x: key.visualFrame.midX,
|
||
y: key.visualFrame.midY
|
||
)
|
||
// Touches go to the UIKit pad; visuals stay for VoiceOver.
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func makeTypingKeyLayout(size: CGSize) -> TypingKeyLayout {
|
||
let isIPad = state.usesIPadLayoutMetrics
|
||
// Select metrics from the same width the controller used to size the
|
||
// keyboard. Using `size` here would compare the grid's own width to
|
||
// its height (always landscape) and could pick a different bucket than
|
||
// the height constraint, clipping the bottom row.
|
||
let metrics = TypingLayoutMetrics.metrics(isIPad: isIPad, width: state.layoutWidth)
|
||
let pageLabel = typing.page == .letters ? "123" : "ABC"
|
||
let spaceLabel = typing.language == .chinese ? "空格" : "space"
|
||
let returnLabel: String = {
|
||
switch state.returnKeyRole {
|
||
case .newline: return "return"
|
||
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
|
||
}
|
||
}()
|
||
|
||
// iPad spends its extra width on comma / period like the system
|
||
// keyboard, instead of stretching the space bar across it.
|
||
let punctuationKeys: TypingKeyLayoutBuilder.PunctuationKeys? = isIPad
|
||
? (typing.language == .chinese
|
||
? .init(comma: ",", period: "。")
|
||
: .init(comma: ",", period: "."))
|
||
: nil
|
||
|
||
let layout = TypingKeyLayoutBuilder.build(
|
||
size: size,
|
||
letterRows: typing.keyRows,
|
||
pageSwitchLabel: pageLabel,
|
||
spaceLabel: spaceLabel,
|
||
returnLabel: returnLabel,
|
||
metrics: metrics,
|
||
includeGlobeKey: state.showsSystemGlobeKey,
|
||
punctuationKeys: punctuationKeys,
|
||
// iPad top letter row carries the small number overlay (1–0),
|
||
// mirroring the iOS system keyboard. iPhone keeps the clean row.
|
||
showTopRowNumbers: isIPad,
|
||
keyWeight: { label, index, rowIndex in
|
||
keyWeight(label: label, index: index, rowIndex: rowIndex)
|
||
}
|
||
)
|
||
let validNext = PinyinNextKeyResolver.validNextKeys(
|
||
rawInput: typing.composition.rawInput,
|
||
schema: typing.schema,
|
||
language: typing.language,
|
||
page: typing.page
|
||
)
|
||
return layout.withHitWeights(
|
||
PinyinNextKeyResolver.hitWeights(for: layout.keys, validNext: validNext)
|
||
)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func visualTypingKey(_ key: TypingKeyHitTarget) -> some View {
|
||
let pressed = highlightedKeyID == key.id
|
||
let isBottom = key.id.hasPrefix("bottom.")
|
||
let isShift = key.label == "⇧"
|
||
let shiftLit = isShift && typing.isShiftEnabled
|
||
let showPressed = pressed || shiftLit
|
||
|
||
Group {
|
||
if key.label == "⌫" {
|
||
Image(systemName: "delete.left")
|
||
.font(.system(size: 20, weight: .medium))
|
||
.foregroundStyle(keyTextColor)
|
||
} else if key.label == "⇧" {
|
||
Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift")
|
||
.font(.system(size: 19, weight: .medium))
|
||
.foregroundStyle(keyTextColor)
|
||
} else if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
|
||
returnKeyLabel
|
||
.foregroundStyle(returnKeyTextColor)
|
||
} else if isBottom {
|
||
let isSpecial = key.id == TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue
|
||
Text(key.label)
|
||
.font(
|
||
.system(
|
||
size: isSpecial ? 15 : 17,
|
||
weight: isSpecial ? .semibold : .regular
|
||
)
|
||
)
|
||
.foregroundStyle(keyTextColor)
|
||
} else {
|
||
// Letter / character key. On iPad the top row carries a small
|
||
// grey number overlay (1–0), mirroring the iOS system keyboard;
|
||
// `displayNumber` is nil everywhere else, so the layout is a
|
||
// single centred letter there.
|
||
VStack(spacing: 1) {
|
||
if let number = key.displayNumber {
|
||
Text(number)
|
||
.font(.system(size: 11, weight: .regular))
|
||
.foregroundStyle(palette.textSecondary.opacity(0.7))
|
||
}
|
||
let isSpecial = ["123", "#+=", "ABC"].contains(key.label)
|
||
Text(key.label)
|
||
.font(
|
||
.system(
|
||
size: isSpecial ? 15 : 22,
|
||
weight: isSpecial ? .semibold : .regular
|
||
)
|
||
)
|
||
.foregroundStyle(keyTextColor)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(
|
||
RoundedRectangle(
|
||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||
style: .continuous
|
||
)
|
||
.fill(visualKeyFill(for: key, pressed: showPressed))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(
|
||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||
style: .continuous
|
||
)
|
||
.stroke(visualKeyBorder(for: key), lineWidth: 0.5)
|
||
)
|
||
.shadow(
|
||
color: Color.black.opacity(showPressed ? 0.04 : 0.13),
|
||
radius: showPressed ? 0.5 : 1,
|
||
y: showPressed ? 0 : 1
|
||
)
|
||
.scaleEffect(pressed ? 0.98 : 1)
|
||
.animation(.easeOut(duration: 0.08), value: pressed)
|
||
.accessibilityElement()
|
||
.accessibilityAddTraits(.isButton)
|
||
.accessibilityLabel(Text(accessibilityLabel(for: key)))
|
||
.accessibilityAction {
|
||
commitTypingKey(key)
|
||
}
|
||
}
|
||
|
||
private func visualKeyFill(for key: TypingKeyHitTarget, pressed: Bool) -> Color {
|
||
if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
|
||
return pressed ? returnKeyPressedFill : returnKeyFill
|
||
}
|
||
return pressed ? keyPressedFill : keyFill
|
||
}
|
||
|
||
private func visualKeyBorder(for key: TypingKeyHitTarget) -> Color {
|
||
if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
|
||
return returnKeyBorder
|
||
}
|
||
return palette.divider
|
||
}
|
||
|
||
private func accessibilityLabel(for key: TypingKeyHitTarget) -> String {
|
||
switch key.id {
|
||
case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue:
|
||
return returnKeyAccessibilityLabel
|
||
case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue:
|
||
return key.label
|
||
default:
|
||
if key.label == "⇧" { return "shift" }
|
||
if key.label == "⌫" { return "delete" }
|
||
return key.label
|
||
}
|
||
}
|
||
|
||
private func commitTypingKey(_ key: TypingKeyHitTarget) {
|
||
switch key.id {
|
||
case TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue:
|
||
typing.setPage(typing.page == .letters ? .numbers : .letters)
|
||
case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue:
|
||
apply(typing.handleSpace())
|
||
case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue:
|
||
apply(typing.handleReturn())
|
||
case TypingKeyLayoutBuilder.BottomKeyID.comma.rawValue,
|
||
TypingKeyLayoutBuilder.BottomKeyID.period.rawValue:
|
||
// Route through the engine so a pending composition commits first,
|
||
// exactly as punctuation typed from the symbols page does.
|
||
apply(typing.handleKey(key.label))
|
||
default:
|
||
switch key.behavior {
|
||
case .commitOnRelease:
|
||
apply(typing.handleKey(key.label))
|
||
case .deleteRepeat:
|
||
apply(typing.handleKey("⌫"))
|
||
case .shiftHold:
|
||
// Mirror a completed Shift tap for VoiceOver / accessibility.
|
||
typing.beginShiftHold()
|
||
typing.endShiftHold()
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var returnKeyLabel: some View {
|
||
switch state.returnKeyRole {
|
||
case .newline:
|
||
Image(systemName: "arrow.turn.down.left")
|
||
.font(.system(size: 21, weight: .medium))
|
||
case .send:
|
||
ExtL10n.text(state.returnKeyRole.titleKey)
|
||
.font(.system(size: 15, weight: .semibold))
|
||
}
|
||
}
|
||
|
||
private func apply(_ output: TypingOutput) {
|
||
guard !output.isEmpty else {
|
||
typing.syncAutocapitalization()
|
||
return
|
||
}
|
||
if output.deleteCount > 0 {
|
||
for _ in 0..<output.deleteCount {
|
||
onDeleteBackward()
|
||
}
|
||
}
|
||
if !output.text.isEmpty {
|
||
onInsert(output.text)
|
||
}
|
||
// Notes / some hosts lag `documentContextBeforeInput` — pass the edit we
|
||
// just applied so sentence Shift can arm after Return / "." immediately.
|
||
typing.syncAutocapitalization(
|
||
accountingForInsert: output.text,
|
||
deleteCount: output.deleteCount
|
||
)
|
||
}
|
||
|
||
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
|
||
// Match system: page switchers + delete are wider than character keys.
|
||
if label == "⌫" || label == "⇧" || label == "#+=" || label == "123" {
|
||
return 1.35
|
||
}
|
||
if rowIndex == 2 && index == 0 {
|
||
// Double-pinyin semicolon occupies the normal Shift footprint.
|
||
return 1.35
|
||
}
|
||
return 1
|
||
}
|
||
|
||
private var keyFill: Color {
|
||
NativeKeyboardKeyColors.fill(for: colorScheme)
|
||
}
|
||
|
||
private var keyPressedFill: Color {
|
||
NativeKeyboardKeyColors.pressedFill(for: colorScheme)
|
||
}
|
||
|
||
private var keyTextColor: Color {
|
||
NativeKeyboardKeyColors.text(for: colorScheme)
|
||
}
|
||
|
||
private var selectedCandidateFill: Color {
|
||
colorScheme == .dark ? Color(white: 0.36) : .white
|
||
}
|
||
|
||
private var returnKeyFill: Color {
|
||
switch state.returnKeyRole {
|
||
case .newline: return keyFill
|
||
case .send: return sendKeyFill
|
||
}
|
||
}
|
||
|
||
private var returnKeyPressedFill: Color {
|
||
switch state.returnKeyRole {
|
||
case .newline: return keyPressedFill
|
||
case .send: return sendKeyPressedFill
|
||
}
|
||
}
|
||
|
||
private var returnKeyBorder: Color {
|
||
switch state.returnKeyRole {
|
||
case .newline:
|
||
return palette.divider
|
||
case .send:
|
||
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
|
||
}
|
||
}
|
||
|
||
private var returnKeyAccessibilityLabel: String {
|
||
switch state.returnKeyRole {
|
||
case .newline: return "return"
|
||
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
|
||
}
|
||
}
|
||
|
||
private var returnKeyTextColor: Color {
|
||
switch state.returnKeyRole {
|
||
case .newline:
|
||
return keyTextColor
|
||
case .send:
|
||
return .white
|
||
}
|
||
}
|
||
|
||
/// The send key stays recognizable in both appearances without becoming neon.
|
||
private var sendKeyFill: Color {
|
||
NativeKeyboardKeyColors.sendFill(for: colorScheme)
|
||
}
|
||
|
||
private var sendKeyPressedFill: Color {
|
||
NativeKeyboardKeyColors.sendPressedFill(for: colorScheme)
|
||
}
|
||
}
|