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:
@@ -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 Norvig’s 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 Norvig’s 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 Norvig’s
|
||||
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 = """
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. Don’t 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. Don’t rename it after adding.";
|
||||
"skills.editor.lookingUp" = "Looking up Shortcut name…";
|
||||
"skills.editor.resolvedName" = "Will run: %@";
|
||||
"skills.editor.lookupFailed" = "Couldn’t 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" = "无法读取捷径名称。请检查链接,或手动填写名称。";
|
||||
|
||||
Reference in New Issue
Block a user