feat(typing): add English autocomplete and Chinese candidate expand panel
Offline English lexicon suggestions with autocapitalization, plus a same-height Chinese more-candidates grid over the key area.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// CandidateExpandGridView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// UIKit grid for the expanded Chinese candidate panel. UILabel cells +
|
||||
// UICollectionView recycling stay far cheaper than hundreds of SwiftUI Buttons.
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct CandidateExpandGridView: UIViewRepresentable {
|
||||
var candidates: [TypingCandidate]
|
||||
var textColor: UIColor
|
||||
var dividerColor: UIColor
|
||||
var onSelect: (Int) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onSelect: onSelect)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UICollectionView {
|
||||
let view = UICollectionView(frame: .zero, collectionViewLayout: Self.makeLayout())
|
||||
view.backgroundColor = .clear
|
||||
view.alwaysBounceVertical = true
|
||||
view.showsVerticalScrollIndicator = true
|
||||
view.delaysContentTouches = false
|
||||
view.canCancelContentTouches = true
|
||||
view.dataSource = context.coordinator
|
||||
view.delegate = context.coordinator
|
||||
view.register(Cell.self, forCellWithReuseIdentifier: Cell.reuseID)
|
||||
context.coordinator.collectionView = view
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UICollectionView, context: Context) {
|
||||
let coordinator = context.coordinator
|
||||
let idsChanged = coordinator.candidates.map(\.id) != candidates.map(\.id)
|
||||
let styleChanged =
|
||||
coordinator.textColor != textColor || coordinator.dividerColor != dividerColor
|
||||
|
||||
coordinator.onSelect = onSelect
|
||||
coordinator.textColor = textColor
|
||||
coordinator.dividerColor = dividerColor
|
||||
coordinator.candidates = candidates
|
||||
|
||||
if idsChanged || styleChanged {
|
||||
uiView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeLayout() -> UICollectionViewCompositionalLayout {
|
||||
let columns = TypingLayoutMetrics.expandGridColumns
|
||||
let itemSize = NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1.0 / CGFloat(columns)),
|
||||
heightDimension: .absolute(TypingLayoutMetrics.expandCellHeight)
|
||||
)
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let groupSize = NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1.0),
|
||||
heightDimension: .absolute(TypingLayoutMetrics.expandCellHeight)
|
||||
)
|
||||
let group = NSCollectionLayoutGroup.horizontal(
|
||||
layoutSize: groupSize,
|
||||
repeatingSubitem: item,
|
||||
count: columns
|
||||
)
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.contentInsets = .zero
|
||||
return UICollectionViewCompositionalLayout(section: section)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate {
|
||||
var candidates: [TypingCandidate] = []
|
||||
var textColor: UIColor = .label
|
||||
var dividerColor: UIColor = .separator
|
||||
var onSelect: (Int) -> Void
|
||||
weak var collectionView: UICollectionView?
|
||||
|
||||
init(onSelect: @escaping (Int) -> Void) {
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
candidates.count
|
||||
}
|
||||
|
||||
func collectionView(
|
||||
_ collectionView: UICollectionView,
|
||||
cellForItemAt indexPath: IndexPath
|
||||
) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: Cell.reuseID,
|
||||
for: indexPath
|
||||
) as! Cell
|
||||
let index = indexPath.item
|
||||
cell.configure(
|
||||
text: candidates[index].text,
|
||||
emphasized: index == 0,
|
||||
textColor: textColor,
|
||||
dividerColor: dividerColor,
|
||||
showDivider: !isLastRow(index: index)
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
collectionView.deselectItem(at: indexPath, animated: false)
|
||||
onSelect(indexPath.item)
|
||||
}
|
||||
|
||||
private func isLastRow(index: Int) -> Bool {
|
||||
let columns = TypingLayoutMetrics.expandGridColumns
|
||||
guard !candidates.isEmpty else { return true }
|
||||
let row = index / columns
|
||||
let lastRow = (candidates.count - 1) / columns
|
||||
return row == lastRow
|
||||
}
|
||||
}
|
||||
|
||||
private final class Cell: UICollectionViewCell {
|
||||
static let reuseID = "CandidateExpandCell"
|
||||
|
||||
private let label = UILabel()
|
||||
private let divider = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
isAccessibilityElement = true
|
||||
accessibilityTraits = .button
|
||||
|
||||
label.textAlignment = .center
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
label.adjustsFontSizeToFitWidth = true
|
||||
label.minimumScaleFactor = 0.7
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(label)
|
||||
|
||||
divider.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(divider)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 2),
|
||||
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -2),
|
||||
label.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
divider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
divider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
divider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
divider.heightAnchor.constraint(equalToConstant: 1.0 / 3.0)
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(
|
||||
text: String,
|
||||
emphasized: Bool,
|
||||
textColor: UIColor,
|
||||
dividerColor: UIColor,
|
||||
showDivider: Bool
|
||||
) {
|
||||
label.text = text
|
||||
label.textColor = textColor
|
||||
label.font = .systemFont(
|
||||
ofSize: emphasized ? 19 : 18,
|
||||
weight: emphasized ? .medium : .regular
|
||||
)
|
||||
divider.backgroundColor = dividerColor
|
||||
divider.isHidden = !showDivider
|
||||
accessibilityLabel = text
|
||||
}
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
contentView.alpha = isHighlighted ? 0.45 : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// TypingRootView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Typing surface (Phase 1): candidate bar + QWERTY / 123 / symbols.
|
||||
// Typing surface: candidate bar + QWERTY / 123 / symbols.
|
||||
// Top-leading control returns to the voice surface.
|
||||
|
||||
import SwiftUI
|
||||
@@ -18,11 +18,17 @@ enum TypingLayoutMetrics {
|
||||
static let verticalKeySpacing: CGFloat = 8
|
||||
static let secondRowInset: CGFloat = 18
|
||||
static let keyCornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
|
||||
/// Extends the bottom corner keys to the same outer edges as Shift / Delete.
|
||||
static let bottomLeadingKeyWidth: CGFloat = 70
|
||||
static let bottomTrailingKeyWidth: CGFloat = 86
|
||||
/// Match the voice surface: compact side keys and a flexible center key.
|
||||
static let bottomSideKeyWidth: CGFloat = KeyboardChromeLayout.sideActionKeyWidth
|
||||
/// 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
|
||||
}
|
||||
|
||||
struct TypingRootView: View {
|
||||
@@ -33,6 +39,10 @@ struct TypingRootView: View {
|
||||
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
|
||||
|
||||
static let totalHeight: CGFloat = TypingLayoutMetrics.totalHeight
|
||||
|
||||
private var palette: ThemePalette {
|
||||
@@ -40,16 +50,37 @@ struct TypingRootView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let expanded = typing.isCandidatePanelExpanded
|
||||
VStack(spacing: 0) {
|
||||
topRegion
|
||||
.frame(height: TypingLayoutMetrics.topRegionHeight)
|
||||
|
||||
keyGrid
|
||||
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
|
||||
// Keep the QWERTY tree mounted; only toggle visibility so ▼/▲
|
||||
// does not destroy GeometryReader key rows every time.
|
||||
ZStack(alignment: .top) {
|
||||
VStack(spacing: 0) {
|
||||
keyGrid
|
||||
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
|
||||
|
||||
bottomRow
|
||||
.frame(height: TypingLayoutMetrics.bottomRowHeight)
|
||||
.padding(.top, TypingLayoutMetrics.keyRowSpacing)
|
||||
bottomRow
|
||||
.frame(height: TypingLayoutMetrics.bottomRowHeight)
|
||||
.padding(.top, TypingLayoutMetrics.keyRowSpacing)
|
||||
}
|
||||
.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)
|
||||
@@ -59,6 +90,12 @@ struct TypingRootView: View {
|
||||
.background(Color.clear)
|
||||
.environment(\.themePalette, palette)
|
||||
.onAppear { typing.enterTypingMode() }
|
||||
.onChange(of: typing.isCandidatePanelExpanded) { _, isExpanded in
|
||||
if isExpanded { candidatePanelMounted = true }
|
||||
}
|
||||
.onChange(of: hasCandidateContent) { _, hasContent in
|
||||
if !hasContent { candidatePanelMounted = false }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared top region
|
||||
@@ -102,46 +139,127 @@ struct TypingRootView: View {
|
||||
// MARK: - Candidates
|
||||
|
||||
private var candidateBar: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if typing.composition.candidates.isEmpty {
|
||||
selectedCandidateLabel(text: typing.composition.preedit)
|
||||
} else {
|
||||
ForEach(
|
||||
Array(typing.composition.candidates.enumerated()),
|
||||
id: \.element.id
|
||||
) { index, candidate in
|
||||
Button {
|
||||
let text = typing.selectCandidate(at: index)
|
||||
if !text.isEmpty { onInsert(text) }
|
||||
} label: {
|
||||
// HStack (not overlay / safeAreaInset): ▼ never paints over candidate text.
|
||||
HStack(spacing: 0) {
|
||||
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 {
|
||||
selectedCandidateLabel(text: candidate.text)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
.padding(.trailing, Spacing.xs)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
|
||||
|
||||
if typing.canExpandCandidatePanel {
|
||||
expandChevronButton
|
||||
} else {
|
||||
Color.clear.frame(width: KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
.padding(.horizontal, 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
|
||||
}
|
||||
|
||||
/// 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)
|
||||
Text(typing.composition.preedit)
|
||||
.font(.system(size: 9, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.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)
|
||||
@@ -180,20 +298,54 @@ struct TypingRootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyButton(_ label: String) -> some View {
|
||||
let isSpecial = ["⇧", "⌫", "123", "#+=", "ABC"].contains(label)
|
||||
return Button {
|
||||
let result = typing.handleKey(label)
|
||||
if result == "\u{8}" {
|
||||
onDeleteBackward()
|
||||
} else if !result.isEmpty {
|
||||
onInsert(result)
|
||||
if label == "⌫" {
|
||||
typingDeleteKey()
|
||||
} else {
|
||||
let isSpecial = ["⇧", "123", "#+=", "ABC"].contains(label)
|
||||
Button {
|
||||
apply(typing.handleKey(label))
|
||||
} label: {
|
||||
keyLabel(label, isSpecial: isSpecial)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} label: {
|
||||
keyLabel(label, isSpecial: isSpecial)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.buttonStyle(nativeKeyStyle)
|
||||
}
|
||||
.buttonStyle(nativeKeyStyle)
|
||||
}
|
||||
|
||||
/// Shares ``RepeatingPressButton`` with the voice toolbar delete key.
|
||||
private func typingDeleteKey() -> some View {
|
||||
RepeatingPressButton {
|
||||
apply(typing.handleKey("⌫"))
|
||||
} label: { isPressed in
|
||||
Image(systemName: "delete.left")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
.fill(isPressed ? keyPressedFill : keyFill)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.shadow(
|
||||
color: Color.black.opacity(isPressed ? 0.04 : 0.13),
|
||||
radius: isPressed ? 0.5 : 1,
|
||||
y: isPressed ? 0 : 1
|
||||
)
|
||||
.scaleEffect(isPressed ? 0.98 : 1)
|
||||
.animation(.easeOut(duration: 0.08), value: isPressed)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -203,10 +355,6 @@ struct TypingRootView: View {
|
||||
Image(systemName: typing.shiftActive || typing.capsLock ? "shift.fill" : "shift")
|
||||
.font(.system(size: 19, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
case "⌫":
|
||||
Image(systemName: "delete.left")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
default:
|
||||
Text(label)
|
||||
.font(
|
||||
@@ -228,15 +376,14 @@ struct TypingRootView: View {
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(keyTextColor)
|
||||
.frame(
|
||||
width: TypingLayoutMetrics.bottomLeadingKeyWidth,
|
||||
width: TypingLayoutMetrics.bottomSideKeyWidth,
|
||||
height: TypingLayoutMetrics.bottomRowHeight
|
||||
)
|
||||
}
|
||||
.buttonStyle(nativeKeyStyle)
|
||||
|
||||
Button {
|
||||
let text = typing.handleSpace()
|
||||
if !text.isEmpty { onInsert(text) }
|
||||
apply(typing.handleSpace())
|
||||
} label: {
|
||||
Text(typing.language == .chinese ? "空格" : "space")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
@@ -247,13 +394,12 @@ struct TypingRootView: View {
|
||||
.buttonStyle(nativeKeyStyle)
|
||||
|
||||
Button {
|
||||
let text = typing.handleReturn()
|
||||
if !text.isEmpty { onInsert(text) }
|
||||
apply(typing.handleReturn())
|
||||
} label: {
|
||||
returnKeyLabel
|
||||
.foregroundStyle(returnKeyTextColor)
|
||||
.frame(
|
||||
width: TypingLayoutMetrics.bottomTrailingKeyWidth,
|
||||
width: TypingLayoutMetrics.bottomSideKeyWidth,
|
||||
height: TypingLayoutMetrics.bottomRowHeight
|
||||
)
|
||||
}
|
||||
@@ -273,6 +419,23 @@ struct TypingRootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Proxy context is up to date after inserts/deletes — refresh Shift.
|
||||
typing.syncAutocapitalization()
|
||||
}
|
||||
|
||||
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
|
||||
if label == "⌫" || label == "⇧" || label == "#+=" {
|
||||
return 1.35
|
||||
|
||||
Reference in New Issue
Block a user