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:
Rocky
2026-08-14 21:49:22 +08:00
parent 2c3a3f80f3
commit 4749a9cbf2
34 changed files with 45539 additions and 3395 deletions
+28 -2
View File
@@ -31,6 +31,7 @@ struct OSGKeyboardApp: App {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--whats-new-host") {
// Approach A: Notes-like host only; real keyboard extension overlays it.
// Also used by `--keyboard-appear-stress=` (pass both flags).
Self.makeWhatsNewHostView()
} else if ProcessInfo.processInfo.arguments.contains("--edit-demo") {
EditDemoView()
@@ -79,8 +80,13 @@ struct OSGKeyboardApp: App {
let scenario = whatsNewScenario(from: args) ?? .edit
let language = whatsNewLanguage(from: args)
let seed = whatsNewSeedText(for: scenario, language: language)
let appearStressCount = keyboardAppearStressCount(from: args)
WhatsNewDemoScenario.clear()
WhatsNewDemoScenario.arm(scenario, seedText: seed, language: language)
// Stress must not arm What's New playback that drives keys on the
// extension while we are tearing it down.
if appearStressCount == 0 {
WhatsNewDemoScenario.arm(scenario, seedText: seed, language: language)
}
if let defaults = AppGroup.defaultsIfAvailable {
defaults.set(true, forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding)
// Force extension ExtL10n / SharedL10n into the demo language.
@@ -98,13 +104,33 @@ struct OSGKeyboardApp: App {
}
defaults.synchronize()
}
if appearStressCount > 0, let defaults = AppGroup.defaultsIfAvailable {
// Hit the crash path: typing surface + English supplementary lexicon.
defaults.set("english", forKey: "typing.input.defaultInputMode")
defaults.set(true, forKey: "typing.input.rememberLastSurface")
defaults.set("typing", forKey: "typing.input.lastSurface")
defaults.set("english", forKey: "typing.input.lastTypingLanguage")
defaults.synchronize()
}
return NotesHostDemoView(
scenario: scenario,
seedText: seed,
language: language
language: language,
appearStressCount: appearStressCount
)
}
private static func keyboardAppearStressCount(from args: [String]) -> Int {
if let paired = args.first(where: { $0.hasPrefix("--keyboard-appear-stress=") }) {
return Int(paired.dropFirst("--keyboard-appear-stress=".count)) ?? 0
}
if let idx = args.firstIndex(of: "--keyboard-appear-stress"),
args.index(after: idx) < args.endIndex {
return Int(args[args.index(after: idx)]) ?? 0
}
return 0
}
private static func whatsNewScenario(from args: [String]) -> WhatsNewDemoScenario? {
if let paired = args.first(where: { $0.hasPrefix("--whats-new-scenario=") }) {
let raw = String(paired.dropFirst("--whats-new-scenario=".count))
@@ -0,0 +1,7 @@
Peter Norvigs n-gram count files (https://norvig.com/ngrams/)
Norvig states: “I hereby release all these files into the public domain.”
OSGKeyboard does not redistribute the raw count files. `Scripts/typing/build_english_lexicon.py`
derives compact log-scaled unigram ranks and a truncated bigram list, then
compiles `english_lexicon.bin` for the keyboard extension to mmap.
@@ -30,6 +30,7 @@ all required copyright and permission notices must remain with distributions.
English typing lexicon
----------------------
english_lexicon.tsv and english_bigrams.tsv are OSG-curated word lists with
synthetic relative frequency ranks for offline autocomplete / autocorrect /
next-word ranking. They are not derived from GPL/LGPL dictionaries.
english_lexicon.bin (from english_lexicon.tsv / english_bigrams.tsv) is an
OSG-curated mmap ranking table with synthetic relative frequency ranks for
offline autocomplete / autocorrect / next-word ranking. It is not derived
from GPL/LGPL dictionaries.
@@ -105,7 +105,7 @@ enum OpenSourceLicenseCatalog {
id: "english-typing-lexicon",
name: "OSG English typing lexicon",
licenseName: "Project-owned notice",
purpose: "Offline English autocomplete, autocorrect, and next-word ranking lists curated by OSGKeyboard (english_lexicon.tsv / english_bigrams.tsv). Not derived from GPL/LGPL dictionaries; relative ranks are ordering weights only.",
purpose: "Offline English autocomplete, autocorrect, and next-word ranking (english_lexicon.bin, compiled from TSV). Log-scaled ranks derived from Peter Norvigs public-domain n-gram counts; not GPL/LGPL dictionaries.",
url: URL(string: "https://github.com/hkgood/OSGKeyboard/blob/main/NOTICE-TYPING.md"),
licenseText: englishLexiconNoticeText,
platforms: [.iOS]
@@ -191,16 +191,17 @@ enum OpenSourceLicenseCatalog {
static let englishLexiconNoticeText = """
OSG English typing lexicon (project-owned notice)
english_lexicon.tsv and english_bigrams.tsv are curated by OSGKeyboard for
offline English autocomplete, autocorrect, and next-word ranking inside the
iOS keyboard extension.
english_lexicon.bin (compiled from english_lexicon.tsv / english_bigrams.tsv)
is the mmap ranking table for offline English autocomplete, autocorrect,
and next-word prediction.
These lists are not derived from GPL or LGPL dictionaries. Relative
frequency values are synthetic ordering weights for ranking only, not
verbatim counts from a single third-party corpus.
Unigram ranks and truncated bigrams are derived from Peter Norvigs
public-domain n-gram count files (https://norvig.com/ngrams/). OSGKeyboard
does not ship the raw corpus. Relative frequency values are log-scaled
ordering weights, not verbatim Google counts.
See NOTICE-TYPING.md in the OSGKeyboard repository for the full typing
keyboard attribution map (Chinese Rime stack vs OSG-owned English data).
keyboard attribution map (Chinese Rime stack vs English data).
"""
static let bsd3Text = """
+23 -7
View File
@@ -334,8 +334,17 @@ struct AIAgentSkillsView: View {
}
private func saveDraft(_ draft: SkillEditorDraft) throws {
guard let url = AIShortcutShareLink.parse(draft.shortcutLink) else {
throw AIUserSkillValidationError.invalidShortcutLink
let rawShortcutLink = draft.shortcutLink.trimmingCharacters(
in: .whitespacesAndNewlines
)
let shortcutURL: URL?
if rawShortcutLink.isEmpty {
shortcutURL = nil
} else {
guard let parsedURL = AIShortcutShareLink.parse(rawShortcutLink) else {
throw AIUserSkillValidationError.invalidShortcutLink
}
shortcutURL = parsedURL
}
let skill = AIUserSkill(
id: draft.id,
@@ -343,7 +352,7 @@ struct AIAgentSkillsView: View {
summary: draft.summary,
systemImage: draft.systemImage,
prompt: draft.prompt,
shortcutICloudURL: url,
shortcutICloudURL: shortcutURL,
shortcutName: draft.shortcutName,
thinkingEnabled: draft.thinkingEnabled
)
@@ -565,7 +574,7 @@ private struct SkillEditorDraft: Identifiable, Equatable {
summary: skill.summary,
systemImage: skill.systemImage,
prompt: skill.prompt,
shortcutLink: skill.shortcutICloudURL.absoluteString,
shortcutLink: skill.shortcutICloudURL?.absoluteString ?? "",
shortcutName: skill.shortcutName,
thinkingEnabled: skill.thinkingEnabled
)
@@ -957,11 +966,18 @@ private struct SkillEditorSheet: View {
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let trimmedShortcutLink = shortcutLink.trimmingCharacters(
in: .whitespacesAndNewlines
)
let validShortcutConfiguration = trimmedShortcutLink.isEmpty
|| (
AIShortcutShareLink.parse(trimmedShortcutLink) != nil
&& !shortcutName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
return !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& prompt.count <= AIUserSkillLimits.maximumPromptCharacters
&& !shortcutName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& AIShortcutShareLink.parse(shortcutLink) != nil
&& validShortcutConfiguration
}
private var currentDraft: SkillEditorDraft {
+143 -2
View File
@@ -14,6 +14,7 @@ struct NotesHostDemoView: View {
let scenario: WhatsNewDemoScenario
let seedText: String
let language: WhatsNewDemoScenario.Language
var appearStressCount: Int = 0
private var title: String {
switch (scenario, language) {
@@ -43,7 +44,7 @@ struct NotesHostDemoView: View {
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
} else {
NotesHostTextView(text: seedText)
NotesHostTextView(text: seedText, appearStressCount: appearStressCount)
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(
@@ -62,6 +63,7 @@ struct NotesHostDemoView: View {
language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans")
)
.task {
guard appearStressCount == 0 else { return }
// Refresh TTL while armed; stop once the extension consumes / plays.
WhatsNewDemoScenario.arm(scenario, seedText: seedText, language: language)
for _ in 0..<25 {
@@ -78,6 +80,7 @@ struct NotesHostDemoView: View {
/// the real custom keyboard extension.
private struct NotesHostTextView: UIViewRepresentable {
let text: String
var appearStressCount: Int = 0
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
@@ -91,8 +94,15 @@ private struct NotesHostTextView: UIViewRepresentable {
view.textContainer.lineFragmentPadding = 0
view.returnKeyType = .default
view.delegate = context.coordinator
view.accessibilityIdentifier = "notes.host.textView"
context.coordinator.appearStressCount = appearStressCount
context.coordinator.textView = view
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
view.becomeFirstResponder()
if context.coordinator.appearStressCount > 0 {
context.coordinator.startAppearStressIfNeeded()
} else {
view.becomeFirstResponder()
}
}
return view
}
@@ -101,6 +111,8 @@ private struct NotesHostTextView: UIViewRepresentable {
if uiView.text != text, !context.coordinator.userEdited {
uiView.text = text
}
// Stress owns first-responder; don't fight resignFirstResponder.
guard appearStressCount == 0 else { return }
if !uiView.isFirstResponder {
DispatchQueue.main.async {
_ = uiView.becomeFirstResponder()
@@ -112,10 +124,139 @@ private struct NotesHostTextView: UIViewRepresentable {
final class Coordinator: NSObject, UITextViewDelegate {
var userEdited = false
var appearStressCount = 0
weak var textView: UITextView?
private var started = false
private var waitingForShow = false
private var waitingForHide = false
private var showWaiter: CheckedContinuation<Bool, Never>?
private var hideWaiter: CheckedContinuation<Bool, Never>?
/// Invalidates leftover timeout tasks from a finished wait.
private var waitGeneration = 0
func textViewDidChange(_ textView: UITextView) {
userEdited = true
}
func startAppearStressIfNeeded() {
guard appearStressCount > 0, !started else { return }
started = true
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardDidShow),
name: UIResponder.keyboardDidShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardDidHide),
name: UIResponder.keyboardDidHideNotification,
object: nil
)
Task { @MainActor [weak self] in
await self?.runAppearStress()
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func keyboardDidShow(_ notification: Notification) {
finishWait(show: true, success: true)
}
@objc private func keyboardDidHide(_ notification: Notification) {
finishWait(show: false, success: true)
}
private func finishWait(show: Bool, success: Bool) {
if show {
guard waitingForShow, let pending = showWaiter else { return }
waitingForShow = false
showWaiter = nil
waitGeneration += 1
pending.resume(returning: success)
} else {
guard waitingForHide, let pending = hideWaiter else { return }
waitingForHide = false
hideWaiter = nil
waitGeneration += 1
pending.resume(returning: success)
}
}
@MainActor
private func runAppearStress() async {
let total = appearStressCount
OSGDiag.log("keyboard.stress begin count=\(total)", category: "boot")
guard let textView else {
OSGDiag.log("keyboard.stress FAIL textView gone", category: "boot")
return
}
guard await becomeAndWaitForShow(textView, timeoutNanoseconds: 8_000_000_000) else {
OSGDiag.log("keyboard.stress FAIL first-show timeout", category: "boot")
return
}
OSGDiag.log("keyboard.stress first-show ok", category: "boot")
var passed = 0
for cycle in 1...total {
guard await resignAndWaitForHide(textView, timeoutNanoseconds: 5_000_000_000) else {
OSGDiag.log("keyboard.stress FAIL cycle=\(cycle) hide timeout", category: "boot")
break
}
try? await Task.sleep(nanoseconds: 350_000_000)
guard await becomeAndWaitForShow(textView, timeoutNanoseconds: 8_000_000_000) else {
OSGDiag.log("keyboard.stress FAIL cycle=\(cycle) show timeout", category: "boot")
break
}
passed += 1
OSGDiag.log("keyboard.stress cycle=\(passed)/\(total) ok", category: "boot")
try? await Task.sleep(nanoseconds: 200_000_000)
}
OSGDiag.log("keyboard.stress done passed=\(passed)/\(total)", category: "boot")
try? await Task.sleep(nanoseconds: 250_000_000)
exit(passed == total ? 0 : 1)
}
@MainActor
private func becomeAndWaitForShow(_ textView: UITextView, timeoutNanoseconds: UInt64) async -> Bool {
await waitForKeyboard(show: true, timeoutNanoseconds: timeoutNanoseconds) {
textView.becomeFirstResponder()
}
}
@MainActor
private func resignAndWaitForHide(_ textView: UITextView, timeoutNanoseconds: UInt64) async -> Bool {
await waitForKeyboard(show: false, timeoutNanoseconds: timeoutNanoseconds) {
textView.resignFirstResponder()
}
}
@MainActor
private func waitForKeyboard(
show: Bool,
timeoutNanoseconds: UInt64,
trigger: () -> Void
) async -> Bool {
await withCheckedContinuation { continuation in
waitGeneration += 1
let generation = waitGeneration
if show {
waitingForShow = true
showWaiter = continuation
} else {
waitingForHide = true
hideWaiter = continuation
}
trigger()
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: timeoutNanoseconds)
guard let self, generation == waitGeneration else { return }
self.finishWait(show: show, success: false)
}
}
}
}
}
+3 -3
View File
@@ -477,10 +477,10 @@
"skills.editor.prompt" = "Processing prompt";
"skills.editor.thinking" = "Thinking";
"skills.editor.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning for this skill.";
"skills.editor.shortcut" = "Shortcut";
"skills.editor.shortcut" = "Shortcut (Optional)";
"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
"skills.editor.shortcutNamePlaceholder" = "Shortcut name (can differ from the skill name)";
"skills.editor.shortcutHint" = "Paste an iCloud share link. The published Shortcut name is filled in automatically and you can change it. Dont rename it in the Shortcuts app after adding.";
"skills.editor.shortcutNamePlaceholder" = "Shortcut name (required with a link)";
"skills.editor.shortcutHint" = "Leave the link empty to process text and insert the result after review. With a link, the Shortcut name is filled in automatically. Dont rename it after adding.";
"skills.editor.lookingUp" = "Looking up Shortcut name…";
"skills.editor.resolvedName" = "Will run: %@";
"skills.editor.lookupFailed" = "Couldnt read the Shortcut name. Check the link, or type the name yourself.";
@@ -476,10 +476,10 @@
"skills.editor.prompt" = "文本处理提示词";
"skills.editor.thinking" = "思考";
"skills.editor.thinkingHint" = "默认关闭。仅在需要该技能更慢、更深的推理时开启。";
"skills.editor.shortcut" = "捷径";
"skills.editor.shortcut" = "捷径(可选)";
"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
"skills.editor.shortcutNamePlaceholder" = "捷径名称(可与技能名称不同";
"skills.editor.shortcutHint" = "粘贴 iCloud 分享链接。发布名称会自动填入,也可以自行修改。添加到「快捷指令」后请勿改名。";
"skills.editor.shortcutNamePlaceholder" = "捷径名称(填写链接时必填";
"skills.editor.shortcutHint" = "不填链接时仅处理文字,结果确认后插入。填写链接时会自动读取捷径名称;添加到「快捷指令」后请勿改名。";
"skills.editor.lookingUp" = "正在读取捷径名称…";
"skills.editor.resolvedName" = "将运行:%@";
"skills.editor.lookupFailed" = "无法读取捷径名称。请检查链接,或手动填写名称。";