feat(keyboard): add English QuickType bar and system lexicon
Show verbatim/correction/completion slots, mmap a 40k-word list, and use UITextChecker plus supplementary lexicon for conservative autocorrect.
This commit is contained in:
@@ -42,6 +42,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private var typingSession: TypingSessionController {
|
||||
if let typingSessionStorage { return typingSessionStorage }
|
||||
let created = TypingSessionController()
|
||||
created.systemLexicon = UIKitEnglishSystemLexicon()
|
||||
typingSessionStorage = created
|
||||
return created
|
||||
}
|
||||
@@ -233,6 +234,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
if state.surface == .typing {
|
||||
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
|
||||
typingSession.enterTypingMode()
|
||||
refreshEnglishSupplementaryLexicon()
|
||||
}
|
||||
clipboardCapture.keyboardDidAppear()
|
||||
OSGDiag.log(
|
||||
@@ -633,6 +635,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.surface = surface
|
||||
if surface == .typing {
|
||||
typingSession.enterTypingMode()
|
||||
refreshEnglishSupplementaryLexicon()
|
||||
} else {
|
||||
typingSession.leaveTypingMode()
|
||||
}
|
||||
@@ -754,6 +757,33 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Contacts and user text replacements, without a Contacts permission.
|
||||
///
|
||||
/// `requestSupplementaryLexicon` replies on `com.apple.TextInput.lexicon-request`,
|
||||
/// not the main actor. Touching `TypingSessionController` there traps in Swift 6
|
||||
/// (`_dispatch_assert_queue_fail`) and the extension is killed on appear.
|
||||
private func refreshEnglishSupplementaryLexicon() {
|
||||
requestSupplementaryLexicon { @Sendable lexicon in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.applySupplementaryLexicon(lexicon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applySupplementaryLexicon(_ lexicon: UILexicon) {
|
||||
typingSessionStorage?.supplementaryWords = lexicon.entries.compactMap { entry -> String? in
|
||||
let text = entry.documentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
let parts = text.split { $0.isWhitespace || $0 == "," }
|
||||
guard parts.count == 1 else { return nil }
|
||||
let token = String(parts[0])
|
||||
guard token.allSatisfy({ $0.isLetter || $0 == "'" || $0 == "’" || $0 == "-" }) else {
|
||||
return nil
|
||||
}
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
private static func typingAutocapitalizationMode(
|
||||
for type: UITextAutocapitalizationType
|
||||
) -> TypingAutocapitalizationMode {
|
||||
|
||||
@@ -142,29 +142,31 @@ struct TypingRootView: View {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
ZStack {
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
@@ -206,47 +208,11 @@ struct TypingRootView: View {
|
||||
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)
|
||||
if typing.language == .english {
|
||||
englishQuickTypeBar
|
||||
} else {
|
||||
chineseCandidateStrip
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
|
||||
|
||||
if typing.canExpandCandidatePanel {
|
||||
expandChevronButton
|
||||
@@ -256,6 +222,90 @@ struct TypingRootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Three equal QuickType slots. Space applies only `role == .correction`.
|
||||
private var englishQuickTypeBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(
|
||||
Array(
|
||||
typing.composition.candidates
|
||||
.prefix(EnglishSuggestionEngine.slotCount)
|
||||
.enumerated()
|
||||
),
|
||||
id: \.element.id
|
||||
) { index, candidate in
|
||||
if index > 0 {
|
||||
Rectangle()
|
||||
.fill(palette.dividerStrong)
|
||||
.frame(width: 1, height: 18)
|
||||
}
|
||||
englishQuickTypeSlot(candidate, index: index)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
|
||||
private func englishQuickTypeSlot(_ candidate: TypingCandidate, index: Int) -> some View {
|
||||
let label = candidate.isQuoted ? "\"\(candidate.text)\"" : candidate.text
|
||||
let weight: Font.Weight = candidate.role == .correction ? .semibold : .regular
|
||||
return Text(label)
|
||||
.font(.system(size: 17, weight: weight))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity, minHeight: 40)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
apply(typing.selectCandidate(at: index))
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(candidate.text)
|
||||
}
|
||||
|
||||
private var chineseCandidateStrip: some View {
|
||||
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)
|
||||
}
|
||||
|
||||
/// Opaque chip like the translation control so ▼ never shares pixels with text.
|
||||
private var expandChevronButton: some View {
|
||||
Button {
|
||||
|
||||
@@ -132,15 +132,17 @@ struct AIKeyboardView: View {
|
||||
)
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
} else {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
ZStack {
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
HStack {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
|
||||
@@ -238,17 +238,19 @@ public struct KeyboardRootView: View {
|
||||
onDismiss: state.dismissClipboardSuggestion
|
||||
)
|
||||
} else {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
// Globe key now lives at the bottom-left of the keyboard (matching
|
||||
// iOS system layout); see micActionRow's bottom HStack.
|
||||
Spacer(minLength: 0)
|
||||
ZStack {
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
HStack {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
// Globe key now lives at the bottom-left of the keyboard (matching
|
||||
// iOS system layout); see micActionRow's bottom HStack.
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,10 @@ enum KeyboardTopBarMetrics {
|
||||
static let horizontalInset: CGFloat = 12
|
||||
/// TypingRootView already contributes 8 pt around the entire key surface.
|
||||
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
|
||||
static let logoHeight: CGFloat = 22
|
||||
static let logoHeight: CGFloat = 16
|
||||
static let logoWidth: CGFloat = logoHeight * 952 / 291
|
||||
/// Equal hit width for AI / Voice / Chinese / English input tabs.
|
||||
static let inputTabWidth: CGFloat = 42
|
||||
/// Shared footprint for top-trailing chips (clipboard, cancel/X, translation).
|
||||
static let trailingChipSize: CGFloat = 34
|
||||
static let trailingChipIconSize: CGFloat = 15
|
||||
@@ -113,39 +115,45 @@ struct KeyboardTopControls: View {
|
||||
let onInsert: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
// 分段轨道:不透明灰底;选中项用白/升高键面滑动,避免半透明发淡。
|
||||
HStack(spacing: 2) {
|
||||
ForEach(KeyboardInputTab.allCases, id: \.self) { tab in
|
||||
tabButton(tab)
|
||||
ZStack {
|
||||
inputTabSwitcher
|
||||
if state.canShowClipboardEntry {
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
KeyboardClipboardMenuButton(
|
||||
palette: palette,
|
||||
action: state.openClipboardPanel
|
||||
)
|
||||
.equatable()
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
.background(tabTrackFill, in: Capsule())
|
||||
.overlay(
|
||||
Capsule().stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
if state.canShowClipboardEntry {
|
||||
KeyboardClipboardMenuButton(
|
||||
palette: palette,
|
||||
action: state.openClipboardPanel
|
||||
)
|
||||
.equatable()
|
||||
private var inputTabSwitcher: some View {
|
||||
// 分段轨道固定在键盘水平中心,不受两侧 Logo / 剪贴板入口影响。
|
||||
HStack(spacing: 2) {
|
||||
ForEach(KeyboardInputTab.allCases, id: \.self) { tab in
|
||||
tabButton(tab)
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
.background(tabTrackFill, in: Capsule())
|
||||
.overlay(
|
||||
Capsule().stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private func tabButton(_ tab: KeyboardInputTab) -> some View {
|
||||
let selected = isSelected(tab)
|
||||
let width: CGFloat = tab == .english || tab == .ai ? 34 : 42
|
||||
|
||||
return Button {
|
||||
withAnimation(Motion.soft) {
|
||||
select(tab)
|
||||
}
|
||||
} label: {
|
||||
tabLabel(tab, selected: selected, width: width)
|
||||
tabLabel(tab, selected: selected)
|
||||
}
|
||||
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
|
||||
.disabled(tab != .voice && !state.canEnterTypingSurface)
|
||||
@@ -157,13 +165,11 @@ struct KeyboardTopControls: View {
|
||||
@ViewBuilder
|
||||
private func tabLabel(
|
||||
_ tab: KeyboardInputTab,
|
||||
selected: Bool,
|
||||
width: CGFloat
|
||||
selected: Bool
|
||||
) -> some View {
|
||||
let label = Text(tab.title)
|
||||
.font(.system(size: 12, weight: selected ? .semibold : .medium))
|
||||
let label = tabContent(tab, selected: selected)
|
||||
.foregroundStyle(selected ? palette.textPrimary : palette.textSecondary)
|
||||
.frame(width: width, height: 30)
|
||||
.frame(width: KeyboardTopBarMetrics.inputTabWidth, height: 30)
|
||||
|
||||
if selected {
|
||||
let namespace = sharedSelectionNamespace ?? fallbackSelectionNamespace
|
||||
@@ -179,6 +185,20 @@ struct KeyboardTopControls: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func tabContent(_ tab: KeyboardInputTab, selected: Bool) -> some View {
|
||||
if tab == .ai {
|
||||
Image(systemName: "sparkle")
|
||||
.font(.system(size: 15, weight: selected ? .semibold : .medium))
|
||||
} else if tab == .voice {
|
||||
Image(systemName: "waveform.mid")
|
||||
.font(.system(size: 15, weight: selected ? .semibold : .medium))
|
||||
} else {
|
||||
Text(tab.title)
|
||||
.font(.system(size: 12, weight: selected ? .semibold : .medium))
|
||||
}
|
||||
}
|
||||
|
||||
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
|
||||
guard tab != .voice, !state.canEnterTypingSurface else { return 1 }
|
||||
if case .recording = state.phase {
|
||||
@@ -191,10 +211,10 @@ struct KeyboardTopControls: View {
|
||||
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
|
||||
}
|
||||
|
||||
/// 分段轨道底色:不透明,且与选中键面(NativeKeyboardKeyColors.fill)拉开明度,
|
||||
/// 深色下压暗、浅色下提亮,让滑动的选中项始终清晰可辨。
|
||||
/// 分段轨道底色与选中键面(NativeKeyboardKeyColors.fill)拉开明度;
|
||||
/// 浅色模式叠加半透明黑色,在不同宿主键盘底色上维持可见对比。
|
||||
private var tabTrackFill: Color {
|
||||
colorScheme == .dark ? Color(white: 0.12) : Color(white: 0.87)
|
||||
colorScheme == .dark ? Color(white: 0.12) : Color.black.opacity(0.12)
|
||||
}
|
||||
|
||||
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
|
||||
|
||||
Reference in New Issue
Block a user