chore(typing): snapshot local pinyin WIP before syncing cloud branch
Preserve the in-progress local typing/pinyin implementation so feat/pinyin can safely reset to origin/feat/pinyin (cloud English + Chinese typing).
This commit is contained in:
@@ -54,6 +54,8 @@ PreconfiguredKeys.local.swift
|
||||
|
||||
# Local DerivedData (when using -derivedDataPath in repo)
|
||||
.derivedData/
|
||||
.derivedData-*/
|
||||
.spm-*/
|
||||
|
||||
# Lexicon build cache (downloaded SogouPopularDict TSV)
|
||||
.cache/
|
||||
@@ -78,6 +80,8 @@ ThirdParty/mlx-audio-swift/
|
||||
AudioIntentProbe/
|
||||
|
||||
# Local crash-log pulls and one-off diff/working notes (not for the repo)
|
||||
.refs/
|
||||
|
||||
.crash-pull/
|
||||
all-changes-vs-main.patch
|
||||
CHANGES.md
|
||||
|
||||
+7
-1
@@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.5.0] - 2026-08-03
|
||||
|
||||
### Added
|
||||
- **Rime typing surface**: keyboard extension adds Chinese/English QWERTY with phrase candidates, full pinyin, Microsoft/Sogou double pinyin, user learning, and opt-in fuzzy pairs; the top-right tab switches back to the voice-first surface. / **Rime 打字表面**:键盘扩展新增中英 QWERTY、词组候选、全拼、微软/搜狗双拼、用户词频学习与可选模糊音;右上角按钮可切回语音主界面。
|
||||
- **macOS direct download**: bilingual README and GitHub Pages download areas now offer a signed, notarized macOS DMG alongside the iPhone / iPad App Store badge, using matching two-line badge designs. / **macOS 直接下载**:中英文 README 与 GitHub Pages 下载区在 iPhone / iPad App Store 徽章旁新增已签名、公证的 macOS DMG,并统一采用双行徽章样式。
|
||||
- **Beams hero**: radiant noise-warped beam array (React Bits–style, vanilla Three.js) with the shared demo preset (26 beams, 208° rotation) and OSG green light. / **Beams Hero**:放射状噪声光束阵列(React Bits 风格,原生 Three.js),采用分享的演示预设(26 束、208° 旋转)与 OSG 绿色灯光。
|
||||
- **Platform download badges**: matching App Store and macOS badges on bilingual READMEs and the website, with localized copy and platform-specific links. / **平台下载徽章**:中英文 README 与官网采用统一样式的 App Store、macOS 徽章,并提供本地化文案与平台专属链接。
|
||||
@@ -22,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Mac Settings translation target**: polish-provider section includes “Polish then translate” with the same locale picker as Home / menu bar. / **Mac 设置翻译目标**:润色(LLM)分区新增「润色后翻译」,与首页 / 菜单栏同一套目标语言选择。
|
||||
|
||||
### Changed
|
||||
- **Unified keyboard chrome**: voice, Chinese, and English now share a 281-point height, aligned native action keys, a tappable OSG wordmark that opens the host app, a compact translation menu, and an adaptive green Send key. / **统一键盘外观**:语音、中文与英文现统一为 281 点高度及对齐的原生操作键;OSG 横向标志可点击打开主 App,翻译入口改为紧凑菜单按钮,发送键使用自适应绿色。
|
||||
- **Native-style typing keyboard**: voice, Chinese, and English now share one top-right segmented control that yields to active candidates; keys are taller, rounder, adapt to light/dark mode, use filled Shift state and native press feedback, while only the first candidate is highlighted. / **原生风格打字键盘**:语音、中文与英文共用右上角分段切换,组词时让位给候选栏;键帽更高、更圆并适配深浅模式,Shift 选中改为实心并增加原生按压反馈,仅首候选高亮。
|
||||
- **Platform-aware third-party licenses**: About now lists the complete iOS Rime/dictionary stack with readable bundled dependency notices, while iOS and macOS hide components they do not ship. / **分平台第三方许可**:「关于」现在完整列出 iOS Rime 与词库依赖及可阅读的传递依赖许可,并在 iOS、macOS 分别隐藏未随该平台分发的组件。
|
||||
- **Landing page single surface**: remove separate install / compare / Mac / English content pages from navigation; keep one bilingual home page with in-page anchors, and leave thin redirects for old URLs. / **官网收成单页**:导航去掉独立的安装 / 对比 / Mac / 英文专题页,保留双语首页与页内锚点,旧 URL 仅保留轻量跳转。
|
||||
- **Hero stays dark**: the beams hero background, overlay, and light text stay on the dark stage in light mode too, matching night mode. / **Hero 固定暗色**:白天模式下 Beams Hero 底色、遮罩与浅色文字仍保持与黑夜一致的暗色舞台。
|
||||
- **Website download actions**: App Store and source buttons now share a compact rounded-rectangle shape and matching height instead of mixing badge and pill silhouettes. / **官网操作按钮**:App Store 与源码按钮统一为等高的紧凑圆角矩形,不再混用徽章与胶囊轮廓。
|
||||
@@ -31,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Practical polish prompts**: Light Clean / Structured / Formal / Daily Chat share a “transcript-only, not a chatbot” boundary; Structured gains active itemization, light semantic reorder, and paragraphing hard rules inspired by high-readability polish patterns. / **实用润色提示词**:轻度清理 / 清晰结构 / 正式表达 / 日常聊天统一「只整理转写、非聊天助手」边界;清晰结构加强积极分项、轻度语义重排与分段硬规则,提升长口述可读性。
|
||||
- **RED Note keeps the draft's audience**: the Xiaohongshu style no longer opens with 姐妹们/集美们 or adds comment CTAs unless the draft already addresses a group, and a positive draft can no longer be rewritten with an 避雷-style hook. / **小红书不再擅自加受众**:除非原文本身在对一群人说话,否则不再添加「姐妹们/集美们」开场与评论区互动话术;正面体验也不会被写成「真诚避雷」式钩子。
|
||||
- **Style-specific forbidden-items chapters**: every built-in polish prompt now has a dedicated `# 禁止事项` section modeled on Daily Chat—no interlocutor replies, no answering question drafts—with per-style bans (e.g. dating must not turn asks into verdicts; flex/corp must not answer as the other party; XHS must not invent product claims). / **风格专属禁止事项**:全部内置润色提示词均新增「# 禁止事项」章节,结构对齐日常聊天(禁接话、禁代答问句),并按风格补充专属禁令(如直男癌不得把征求意见改成评价;装逼/黑话不得替对方作答;小红书不得编造功效细节)。
|
||||
- **Settings hierarchy**: voice-session options join Daily, while ASR and LLM configuration links sit directly below the transcription-mode choices; General and About remain secondary pages. / **设置层级**:语音会话选项并入「日常」,ASR 与 LLM 配置入口紧跟转写模式选择;通用与关于保留为二级页。
|
||||
- **Settings hierarchy**: General is now the first Daily entry; Text Input and its opt-in default-surface switch live under Keyboard & Gestures, while ASR and LLM configuration links remain below the transcription choices. / **设置层级**:「通用」现为「日常」首项;「文本输入」及默认进入文字键盘的开关移入「键盘与操作」,ASR 与 LLM 配置入口仍位于转写选项下方。
|
||||
- **Transcription option rows**: local and cloud choices now use the same text-first list-row style as the rest of Settings, without leading icons. / **转写选项行**:本地与云端选项移除前置图标,统一采用设置页的文字优先列表样式。
|
||||
- **Simplified style cards and summaries**: polish-style cards drop decorative badges, and speech-configuration summaries show only the active engine or provider/model without redundant status prefixes. / **简化风格卡与摘要**:润色风格卡移除装饰图标;语音配置摘要仅显示引擎或服务商/模型,不再附加冗余状态前缀。
|
||||
- **Mac polish style grid**: cards drop decorative icons and use a denser adaptive grid (about three columns at the default window; two when narrower, four+ when wider). / **Mac 润色风格网格**:卡片去掉装饰图标,并以更密的自适应网格排布(默认窗口约三列;变窄两列、变宽四列及以上)。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Third-party notices — Typing keyboard
|
||||
|
||||
The generated source manifest is
|
||||
`OSGKeyboardShared/Resources/Typing/Rime/manifest.json`. Exact librime
|
||||
binary dependency licenses are bundled beside `NOTICE.txt`.
|
||||
|
||||
## Distributed components
|
||||
|
||||
| Component | Pinned version / commit | License | Purpose |
|
||||
|-----------|-------------------------|---------|---------|
|
||||
| [librime-xcframework](https://github.com/ghostflyby/librime-xcframework) | `1.17.0-pack.1`, package `d578192…` | BSD-3-Clause + bundled dependency notices | Static iOS librime runtime |
|
||||
| [rime/librime](https://github.com/rime/librime) | `1.17.0` | BSD-3-Clause | Input-method engine |
|
||||
| [rime-pinyin-simp](https://github.com/rime/rime-pinyin-simp) | `0c6861e…` | Apache-2.0 | Permissive baseline dictionary |
|
||||
| [fxsjy/jieba](https://github.com/fxsjy/jieba) | `67fa2e3…` | MIT | Modern Simplified-Chinese word frequencies |
|
||||
| [phrase-pinyin-data](https://github.com/mozillazg/phrase-pinyin-data) | `cee0ed6…` | MIT | Phrase pronunciations |
|
||||
| [pinyin-data](https://github.com/mozillazg/pinyin-data) | `923b108…` | MIT | Character-pronunciation fallback |
|
||||
|
||||
`Scripts/typing/build_rime_dictionary.py` deterministically merges these
|
||||
sources into `osg_pinyin.dict.yaml`; its manifest records every source URL,
|
||||
commit, SHA-256 and output SHA-256.
|
||||
|
||||
## OSG-owned pieces
|
||||
|
||||
- SwiftUI QWERTY / candidate UI and `RimeEngineBridging`.
|
||||
- Objective-C++ C-API bridge.
|
||||
- Full-pinyin, Microsoft-double-pinyin and Sogou-double-pinyin schemas.
|
||||
- Microsoft/Sogou mappings were independently encoded from public key-map
|
||||
specifications; GPL schema files were not copied.
|
||||
- Eight opt-in fuzzy-pinyin rule groups.
|
||||
|
||||
## Reference only
|
||||
|
||||
NanoMouse commit `a6177d898a01662ce551b43a01cf82a9f84ca54c`
|
||||
(MIT) was used to understand iOS Rime lifecycle boundaries. Its KeyboardKit,
|
||||
UI, data packages and application features are not linked into OSGKeyboard.
|
||||
|
||||
## Explicitly excluded
|
||||
|
||||
- rime-ice / 雾凇拼音 — GPL-3.0
|
||||
- rime-double-pinyin schemas — GPL-3.0
|
||||
- rime-luna-pinyin and rime-essay — LGPL-3.0
|
||||
- KeyboardKit Pro — proprietary
|
||||
@@ -31,11 +31,12 @@
|
||||
<li><strong>Transcribed text</strong> — in Cloud polish mode, the final text (not audio) may be sent to the LLM provider you configure (e.g. OpenAI) for punctuation and formatting.</li>
|
||||
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
|
||||
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors preferences, usage statistics, and voice history through your private iCloud account.</li>
|
||||
<li><strong>On-device typing learning</strong> — the Chinese keyboard stores selected words and candidate frequencies in the App Group on your device. OSGKeyboard does not upload this user dictionary.</li>
|
||||
</ul>
|
||||
|
||||
<h2>What we do not collect</h2>
|
||||
<ul>
|
||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes you type with the keyboard.</li>
|
||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.</li>
|
||||
<li>We do <strong>not</strong> operate analytics or advertising SDKs.</li>
|
||||
<li>We do <strong>not</strong> sell personal data.</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
// OpenSourceLicenseCatalog.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Single source of truth for third-party open-source components shipped
|
||||
// with OSGKeyboard. Consumed by Settings → About → Third-Party Licenses.
|
||||
// Single source of truth for third-party components distributed by each
|
||||
// OSGKeyboard platform. Consumed by Settings → About → Third-Party Licenses.
|
||||
//
|
||||
// Keep this list aligned with bundled resources and runtime downloads.
|
||||
//
|
||||
// iOS targets remain zero-SPM. macOS local ASR links mlx-audio-swift (vendored
|
||||
// under ThirdParty/) for Qwen3 MLX streaming; model weights download via catalog.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum OpenSourceLicenseCatalog {
|
||||
|
||||
enum Platform: Hashable {
|
||||
case iOS
|
||||
case macOS
|
||||
}
|
||||
|
||||
struct Entry: Identifiable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
@@ -22,17 +24,82 @@ enum OpenSourceLicenseCatalog {
|
||||
let url: URL?
|
||||
/// Verbatim license body for the long-scroll disclosure page.
|
||||
let licenseText: String
|
||||
let platforms: Set<Platform>
|
||||
}
|
||||
|
||||
/// Bundled libraries and runtime components referenced by the app.
|
||||
static let entries: [Entry] = [
|
||||
private static let allEntries: [Entry] = [
|
||||
.init(
|
||||
id: "material-icons",
|
||||
name: "Google Material Icons",
|
||||
licenseName: "Apache-2.0",
|
||||
purpose: "MaterialIcons-Regular.ttf bundled with the iOS app for Settings and navigation iconography.",
|
||||
url: URL(string: "https://github.com/google/material-design-icons"),
|
||||
licenseText: apache2Text
|
||||
licenseText: resourceText(
|
||||
named: "LICENSE-PINYIN-SIMP-APACHE",
|
||||
fallback: apache2Text
|
||||
),
|
||||
platforms: [.iOS]
|
||||
),
|
||||
.init(
|
||||
id: "librime-static",
|
||||
name: "librime 1.17.0 + static dependencies",
|
||||
licenseName: "BSD-3-Clause and others",
|
||||
purpose: "Chinese input runtime packaged by librime-xcframework 1.17.0-pack.1. Includes complete notices for Boost, OpenCC, LevelDB, yaml-cpp, glog, marisa-trie, RapidJSON, Darts and other statically linked components.",
|
||||
url: URL(string: "https://github.com/ghostflyby/librime-xcframework"),
|
||||
licenseText: resourceText(
|
||||
named: "LIBRIME-COMBINED-NOTICES",
|
||||
fallback: bsd3Text
|
||||
),
|
||||
platforms: [.iOS]
|
||||
),
|
||||
.init(
|
||||
id: "rime-pinyin-simp",
|
||||
name: "rime-pinyin-simp",
|
||||
licenseName: "Apache-2.0",
|
||||
purpose: "Permissive Simplified-Chinese baseline dictionary used to generate OSGKeyboard's Rime lexicon.",
|
||||
url: URL(string: "https://github.com/rime/rime-pinyin-simp"),
|
||||
licenseText: resourceText(
|
||||
named: "LICENSE-PINYIN-SIMP-APACHE",
|
||||
fallback: apache2Text
|
||||
),
|
||||
platforms: [.iOS]
|
||||
),
|
||||
.init(
|
||||
id: "jieba",
|
||||
name: "Jieba",
|
||||
licenseName: "MIT",
|
||||
purpose: "Modern Simplified-Chinese words and frequency weights used by the generated typing dictionary.",
|
||||
url: URL(string: "https://github.com/fxsjy/jieba"),
|
||||
licenseText: resourceText(
|
||||
named: "LICENSE-JIEBA-MIT",
|
||||
fallback: mitText
|
||||
),
|
||||
platforms: [.iOS]
|
||||
),
|
||||
.init(
|
||||
id: "phrase-pinyin-data",
|
||||
name: "phrase-pinyin-data",
|
||||
licenseName: "MIT",
|
||||
purpose: "Phrase pronunciation data used to resolve polyphonic Chinese words.",
|
||||
url: URL(string: "https://github.com/mozillazg/phrase-pinyin-data"),
|
||||
licenseText: resourceText(
|
||||
named: "LICENSE-PHRASE-PINYIN-DATA-MIT",
|
||||
fallback: mitText
|
||||
),
|
||||
platforms: [.iOS]
|
||||
),
|
||||
.init(
|
||||
id: "pinyin-data",
|
||||
name: "pinyin-data",
|
||||
licenseName: "MIT",
|
||||
purpose: "Per-character Mandarin pronunciation fallback for generated dictionary entries.",
|
||||
url: URL(string: "https://github.com/mozillazg/pinyin-data"),
|
||||
licenseText: resourceText(
|
||||
named: "LICENSE-PINYIN-DATA-MIT",
|
||||
fallback: mitText
|
||||
),
|
||||
platforms: [.iOS]
|
||||
),
|
||||
.init(
|
||||
id: "mlx-audio-swift",
|
||||
@@ -40,10 +107,28 @@ enum OpenSourceLicenseCatalog {
|
||||
licenseName: "MIT",
|
||||
purpose: "macOS local Qwen3 MLX streaming ASR (MLXAudioSTT), linked only in the Mac app target.",
|
||||
url: URL(string: "https://github.com/Blaizzy/mlx-audio-swift"),
|
||||
licenseText: mitText
|
||||
licenseText: mlxAudioMITText,
|
||||
platforms: [.macOS]
|
||||
),
|
||||
]
|
||||
|
||||
static func entries(for platform: Platform) -> [Entry] {
|
||||
allEntries.filter { $0.platforms.contains(platform) }
|
||||
}
|
||||
|
||||
private static func resourceText(named name: String, fallback: String) -> String {
|
||||
let bundles = Bundle.allFrameworks + Bundle.allBundles + [Bundle.main]
|
||||
for bundle in bundles {
|
||||
guard let url = bundle.url(forResource: name, withExtension: "txt"),
|
||||
let text = try? String(contentsOf: url, encoding: .utf8),
|
||||
!text.isEmpty else {
|
||||
continue
|
||||
}
|
||||
return text
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// MARK: - License bodies
|
||||
|
||||
static let apache2Text = """
|
||||
@@ -85,4 +170,36 @@ enum OpenSourceLicenseCatalog {
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"""
|
||||
|
||||
static let mlxAudioMITText = """
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Prince Canuma
|
||||
|
||||
\(mitText.components(separatedBy: "\n").dropFirst(2).joined(separator: "\n"))
|
||||
"""
|
||||
|
||||
static let bsd3Text = """
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2014, RIME Developers
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DAMAGES ARISING IN ANY WAY
|
||||
OUT OF THE USE OF THIS SOFTWARE.
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -68,6 +68,12 @@ struct MainAppRoot: View {
|
||||
Task {
|
||||
await AppCloudSync.shared.pullAllIfEnabled()
|
||||
}
|
||||
Task {
|
||||
let typingConfig = TypingInputConfiguration.shared.snapshot
|
||||
try? await RimeResourceInstaller.shared.installIfNeeded(
|
||||
configuration: typingConfig
|
||||
)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
config.reloadFromPersistedStorage()
|
||||
|
||||
@@ -11,6 +11,8 @@ import OSGKeyboardShared
|
||||
struct OpenSourceLicensesView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
private let entries = OpenSourceLicenseCatalog.entries(for: .iOS)
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent(spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
@@ -21,7 +23,7 @@ struct OpenSourceLicensesView: View {
|
||||
.padding(.horizontal, Spacing.xs)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(OpenSourceLicenseCatalog.entries.enumerated()), id: \.element.id) { index, entry in
|
||||
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
|
||||
NavigationLink {
|
||||
OpenSourceLicenseDetailView(entry: entry)
|
||||
} label: {
|
||||
@@ -29,7 +31,7 @@ struct OpenSourceLicensesView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if index < OpenSourceLicenseCatalog.entries.count - 1 {
|
||||
if index < entries.count - 1 {
|
||||
Divider().background(palette.divider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,29 @@ struct PolishIntensityPickerRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Default input surface toggle
|
||||
|
||||
struct DefaultTypingInputToggleRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Binding var isOn: Bool
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: $isOn) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("settings.typingInput.default.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.typingInput.default.description")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.settingsListRow()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cursor drag navigation toggle
|
||||
|
||||
struct CursorDragNavigationToggleRow: View {
|
||||
|
||||
@@ -221,6 +221,7 @@ struct VoiceSessionSettingsRows: View {
|
||||
struct GeneralSettingsView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@ObservedObject private var typingConfiguration = TypingInputConfiguration.shared
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -241,6 +242,21 @@ struct GeneralSettingsView: View {
|
||||
|
||||
CardSection("settings.general.keyboard.title") {
|
||||
VStack(spacing: 0) {
|
||||
DefaultTypingInputToggleRow(
|
||||
isOn: $typingConfiguration.defaultToTyping
|
||||
)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
NavigationLink {
|
||||
TypingInputSettingsView()
|
||||
} label: {
|
||||
SettingsNavigationRow(title: "settings.typingInput.title")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
HandednessPickerRow(
|
||||
selection: Binding(
|
||||
get: { config.handednessPreference },
|
||||
|
||||
@@ -50,7 +50,9 @@ struct SettingsView: View {
|
||||
}
|
||||
dailySection
|
||||
transcriptionAndPolishSection
|
||||
moreEntriesSection
|
||||
if presentation == .tab {
|
||||
moreEntriesSection
|
||||
}
|
||||
}
|
||||
.modifier(SettingsScrollBottomPadding(presentation: presentation))
|
||||
}
|
||||
@@ -114,6 +116,10 @@ struct SettingsView: View {
|
||||
private var dailySection: some View {
|
||||
CardSection("settings.daily.title") {
|
||||
VStack(spacing: 0) {
|
||||
settingsRouteButton(.general, title: "settings.general.title")
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
LocalePickerRow(
|
||||
locales: effectiveLocales,
|
||||
selection: Binding(
|
||||
@@ -160,16 +166,11 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - General / About
|
||||
// MARK: - About
|
||||
|
||||
private var moreEntriesSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
settingsRouteButton(.general, title: "settings.general.title")
|
||||
|
||||
if presentation == .tab {
|
||||
Divider().background(palette.divider)
|
||||
settingsRouteButton(.about, title: "settings.about.title")
|
||||
}
|
||||
settingsRouteButton(.about, title: "settings.about.title")
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// TypingInputSettingsView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Schema and opt-in fuzzy-pinyin settings. Changing fuzzy pairs triggers
|
||||
// host-side redeployment; the keyboard extension never compiles schemas.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct TypingInputSettingsView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject private var configuration = TypingInputConfiguration.shared
|
||||
|
||||
@State private var isDeploying = false
|
||||
@State private var deploymentError: String?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("输入方案") {
|
||||
Picker("拼音方案", selection: $configuration.schema) {
|
||||
ForEach(TypingInputSchema.allCases) { schema in
|
||||
Text(schema.displayName).tag(schema)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(PinyinFuzzyPair.allCases) { pair in
|
||||
Toggle(
|
||||
pair.displayName,
|
||||
isOn: Binding(
|
||||
get: { configuration.fuzzyPairs.contains(pair) },
|
||||
set: { enabled in
|
||||
configuration.setFuzzyPair(pair, enabled: enabled)
|
||||
deployUpdatedSchemas()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("模糊音")
|
||||
} footer: {
|
||||
Text("默认全部关闭。只开启你需要的组合,避免候选噪音。")
|
||||
}
|
||||
|
||||
Section("输入法资源") {
|
||||
HStack {
|
||||
Text("状态")
|
||||
Spacer()
|
||||
if isDeploying {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(statusText)
|
||||
.foregroundStyle(
|
||||
deploymentError == nil ? palette.textSecondary : palette.danger
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button("重新部署输入法资源") {
|
||||
deployUpdatedSchemas()
|
||||
}
|
||||
.disabled(isDeploying)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.navigationTitle("settings.typingInput.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let deploymentError { return deploymentError }
|
||||
return RimeResourceInstaller.isReady ? "已就绪" : "待初始化"
|
||||
}
|
||||
|
||||
private func deployUpdatedSchemas() {
|
||||
guard !isDeploying else { return }
|
||||
let snapshot = configuration.snapshot
|
||||
isDeploying = true
|
||||
deploymentError = nil
|
||||
Task {
|
||||
do {
|
||||
try await RimeResourceInstaller.shared.installIfNeeded(
|
||||
configuration: snapshot,
|
||||
force: true
|
||||
)
|
||||
} catch {
|
||||
deploymentError = error.localizedDescription
|
||||
}
|
||||
isDeploying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,9 @@
|
||||
"settings.general.appearanceLanguage.title" = "Appearance & Language";
|
||||
"settings.general.keyboard.title" = "Keyboard & Gestures";
|
||||
"settings.general.sync.title" = "Sync";
|
||||
"settings.typingInput.title" = "Text Input";
|
||||
"settings.typingInput.default.title" = "Default to Text Input";
|
||||
"settings.typingInput.default.description" = "Open the text input keyboard instead of voice input by default";
|
||||
"settings.speechRecognition.title" = "Speech Recognition";
|
||||
"settings.textPolish.title" = "Text Polish";
|
||||
"settings.preferences.title" = "Preferences";
|
||||
|
||||
@@ -184,6 +184,9 @@
|
||||
"settings.general.appearanceLanguage.title" = "外观与语言";
|
||||
"settings.general.keyboard.title" = "键盘与操作";
|
||||
"settings.general.sync.title" = "同步";
|
||||
"settings.typingInput.title" = "文本输入";
|
||||
"settings.typingInput.default.title" = "默认进行文字输入";
|
||||
"settings.typingInput.default.description" = "打开键盘时,默认使用文字输入键盘而非语音输入";
|
||||
"settings.speechRecognition.title" = "语音识别配置";
|
||||
"settings.textPolish.title" = "文本润色配置";
|
||||
"settings.preferences.title" = "偏好设置";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "OSGLogoWide.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg width="952" height="291" viewBox="36 367 952 291" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
|
||||
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
|
||||
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
|
||||
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z" fill="white"/>
|
||||
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -16,9 +16,10 @@
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import OSGKeyboardShared
|
||||
|
||||
private final class KeyboardHostingController: UIHostingController<KeyboardRootView> {
|
||||
private final class KeyboardHostingController: UIHostingController<KeyboardSurfaceRoot> {
|
||||
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
|
||||
[.left, .right]
|
||||
}
|
||||
@@ -35,11 +36,13 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
public typealias State = KeyboardState
|
||||
|
||||
private let state = State()
|
||||
private let typingSession = TypingSessionController()
|
||||
private let persistor = AppGroupPersistor()
|
||||
|
||||
private var hosting: UIHostingController<KeyboardRootView>?
|
||||
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
|
||||
private var keyboardHeightConstraint: NSLayoutConstraint?
|
||||
private var systemEncapsulatedHeight: CGFloat = 228
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var textInserter: KeyboardTextInserter!
|
||||
private var flowCoordinator: KeyboardFlowCoordinator!
|
||||
@@ -47,7 +50,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private var cursorDrag: CursorDragController!
|
||||
|
||||
private var targetKeyboardHeight: CGFloat {
|
||||
KeyboardRootView.totalHeight
|
||||
KeyboardSurfaceRoot.height(for: state.surface)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
@@ -67,6 +70,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
configureDictationBehavior()
|
||||
installServices()
|
||||
installStateActions()
|
||||
installSurfaceObservers()
|
||||
installSwiftUI()
|
||||
_ = configSync.loadPersistedConfig()
|
||||
configSync.installDarwinObservers()
|
||||
@@ -76,6 +80,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
flowCoordinator.stopSessionMonitor()
|
||||
if state.surface == .typing {
|
||||
typingSession.leaveTypingMode()
|
||||
}
|
||||
if flowCoordinator.preservesLifecycleOnDisappear {
|
||||
return
|
||||
}
|
||||
@@ -93,6 +100,10 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
flowCoordinator.startSessionMonitor()
|
||||
configSync.syncOnboardingStateFromAppGroup()
|
||||
configSync.refreshConfigFromAppGroup()
|
||||
applyPreferredSurfaceOnOpen()
|
||||
if state.surface == .typing {
|
||||
typingSession.enterTypingMode()
|
||||
}
|
||||
configSync.autoAdvancePastKeyboardSetupStepIfNeeded()
|
||||
}
|
||||
|
||||
@@ -124,6 +135,12 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
public override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
flowCoordinator.cancelPipelineUnlessAwaitingResult()
|
||||
if state.surface == .typing {
|
||||
typingSession.leaveTypingMode()
|
||||
applySurface(.voice)
|
||||
} else {
|
||||
typingSession.leaveTypingMode()
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewDidLayoutSubviews() {
|
||||
@@ -200,6 +217,62 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.setCursorDragActive = { [weak self] active in
|
||||
self?.cursorDrag.setCursorDragActive(active)
|
||||
}
|
||||
state.setSurface = { [weak self] surface in
|
||||
self?.applySurface(surface)
|
||||
}
|
||||
}
|
||||
|
||||
private func installSurfaceObservers() {
|
||||
state.$phase
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
if self.state.locksTypingSurface, self.state.surface == .typing {
|
||||
self.applySurface(.voice)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
state.$surface
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshKeyboardHeight()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func applySurface(_ surface: State.Surface) {
|
||||
if surface == .typing, state.locksTypingSurface {
|
||||
return
|
||||
}
|
||||
guard state.surface != surface else {
|
||||
refreshKeyboardHeight()
|
||||
return
|
||||
}
|
||||
state.surface = surface
|
||||
if surface == .voice {
|
||||
typingSession.leaveTypingMode()
|
||||
}
|
||||
refreshKeyboardHeight()
|
||||
}
|
||||
|
||||
private func applyPreferredSurfaceOnOpen() {
|
||||
let preferredSurface: State.Surface = TypingInputConfiguration.prefersTypingOnOpen()
|
||||
? .typing
|
||||
: .voice
|
||||
applySurface(preferredSurface)
|
||||
}
|
||||
|
||||
private func refreshKeyboardHeight() {
|
||||
// `applyPresentationHeightOffset()` is only a one-time presentation
|
||||
// primer used before `viewDidAppear`. Reusing it after a surface
|
||||
// switch subtracts the system's ~228 pt encapsulated height from the
|
||||
// requested typing height and collapses the keyboard to a thin strip.
|
||||
// Once presented, update our height constraint directly, matching the
|
||||
// final assignment in `viewDidAppear`.
|
||||
keyboardHeightConstraint?.constant = targetKeyboardHeight
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
private func refreshReturnKeyRole() {
|
||||
@@ -283,7 +356,16 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
|
||||
private func installSwiftUI() {
|
||||
let root = KeyboardRootView(state: state)
|
||||
let root = KeyboardSurfaceRoot(
|
||||
state: state,
|
||||
typing: typingSession,
|
||||
onInsert: { [weak self] text in
|
||||
self?.textDocumentProxy.insertText(text)
|
||||
},
|
||||
onDeleteBackward: { [weak self] in
|
||||
self?.textDocumentProxy.deleteBackward()
|
||||
}
|
||||
)
|
||||
let host = KeyboardHostingController(rootView: root)
|
||||
host.view.backgroundColor = .clear
|
||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// KeyboardSurfaceRoot.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Switches between voice and typing surfaces driven by KeyboardState.surface.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct KeyboardSurfaceRoot: View {
|
||||
@ObservedObject var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
var onInsert: (String) -> Void
|
||||
var onDeleteBackward: () -> Void
|
||||
|
||||
static var voiceHeight: CGFloat { KeyboardRootView.totalHeight }
|
||||
static var typingHeight: CGFloat { TypingRootView.totalHeight }
|
||||
|
||||
static func height(for surface: KeyboardState.Surface) -> CGFloat {
|
||||
switch surface {
|
||||
case .voice: return voiceHeight
|
||||
case .typing: return typingHeight
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state.surface {
|
||||
case .voice:
|
||||
KeyboardRootView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: onInsert
|
||||
)
|
||||
case .typing:
|
||||
TypingRootView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: onInsert,
|
||||
onDeleteBackward: onDeleteBackward
|
||||
)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.15), value: state.surface)
|
||||
.onChange(of: state.surface) { _, newSurface in
|
||||
if newSurface == .voice {
|
||||
typing.leaveTypingMode()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
// TypingRootView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Typing surface (Phase 1): candidate bar + QWERTY / 123 / symbols.
|
||||
// Top-leading control returns to the voice surface.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum TypingLayoutMetrics {
|
||||
static let outerPaddingTop: CGFloat = 4
|
||||
static let outerPaddingBottom: CGFloat = 4
|
||||
static let topRegionHeight: CGFloat = KeyboardTopBarMetrics.height
|
||||
static let keyRowHeight: CGFloat = 50
|
||||
static let keyRowSpacing: CGFloat = 7
|
||||
static let keyHorizontalSpacing: CGFloat = 6
|
||||
static let bottomRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
|
||||
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
|
||||
/// Shared top row + three 50 pt key rows + native spacing + bottom row.
|
||||
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
|
||||
}
|
||||
|
||||
struct TypingRootView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
var onInsert: (String) -> Void
|
||||
var onDeleteBackward: () -> Void
|
||||
|
||||
static let totalHeight: CGFloat = TypingLayoutMetrics.totalHeight
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topRegion
|
||||
.frame(height: TypingLayoutMetrics.topRegionHeight)
|
||||
|
||||
keyGrid
|
||||
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
|
||||
|
||||
bottomRow
|
||||
.frame(height: TypingLayoutMetrics.bottomRowHeight)
|
||||
.padding(.top, TypingLayoutMetrics.keyRowSpacing)
|
||||
}
|
||||
.padding(.top, TypingLayoutMetrics.outerPaddingTop)
|
||||
.padding(.bottom, TypingLayoutMetrics.outerPaddingBottom)
|
||||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: Self.totalHeight)
|
||||
.background(Color.clear)
|
||||
.environment(\.themePalette, palette)
|
||||
.onAppear { typing.enterTypingMode() }
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
if let err = typing.lastError {
|
||||
Text(err)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(palette.danger)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
|
||||
// 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: {
|
||||
if index == 0 {
|
||||
selectedCandidateLabel(text: candidate.text)
|
||||
} else {
|
||||
Text(candidate.text)
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 40)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
.foregroundStyle(keyTextColor)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 56, minHeight: 40)
|
||||
.background(
|
||||
selectedCandidateFill,
|
||||
in: RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Keys
|
||||
|
||||
private var keyGrid: some View {
|
||||
VStack(spacing: TypingLayoutMetrics.keyRowSpacing) {
|
||||
ForEach(Array(typing.keyRows.enumerated()), id: \.offset) { rowIndex, row in
|
||||
GeometryReader { proxy in
|
||||
let inset = rowIndex == 1 ? TypingLayoutMetrics.secondRowInset : 0
|
||||
let weights = row.enumerated().map {
|
||||
keyWeight(label: $0.element, index: $0.offset, rowIndex: rowIndex)
|
||||
}
|
||||
let spacing = TypingLayoutMetrics.keyHorizontalSpacing
|
||||
* CGFloat(max(0, row.count - 1))
|
||||
let availableWidth = proxy.size.width - inset * 2 - spacing
|
||||
let unitWidth = availableWidth / max(1, weights.reduce(0, +))
|
||||
|
||||
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
|
||||
ForEach(Array(row.enumerated()), id: \.offset) { keyIndex, key in
|
||||
keyButton(key)
|
||||
.frame(width: unitWidth * weights[keyIndex])
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, inset)
|
||||
}
|
||||
.frame(height: TypingLayoutMetrics.keyRowHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
} label: {
|
||||
keyLabel(label, isSpecial: isSpecial)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.buttonStyle(nativeKeyStyle)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyLabel(_ label: String, isSpecial: Bool) -> some View {
|
||||
switch label {
|
||||
case "⇧":
|
||||
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(
|
||||
.system(
|
||||
size: isSpecial ? 15 : 22,
|
||||
weight: isSpecial ? .semibold : .regular
|
||||
)
|
||||
)
|
||||
.foregroundStyle(keyTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomRow: some View {
|
||||
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
|
||||
Button {
|
||||
typing.setPage(typing.page == .letters ? .numbers : .letters)
|
||||
} label: {
|
||||
Text(typing.page == .letters ? "123" : "ABC")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(keyTextColor)
|
||||
.frame(
|
||||
width: TypingLayoutMetrics.bottomLeadingKeyWidth,
|
||||
height: TypingLayoutMetrics.bottomRowHeight
|
||||
)
|
||||
}
|
||||
.buttonStyle(nativeKeyStyle)
|
||||
|
||||
Button {
|
||||
let text = typing.handleSpace()
|
||||
if !text.isEmpty { onInsert(text) }
|
||||
} label: {
|
||||
Text(typing.language == .chinese ? "空格" : "space")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(keyTextColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: TypingLayoutMetrics.bottomRowHeight)
|
||||
}
|
||||
.buttonStyle(nativeKeyStyle)
|
||||
|
||||
Button {
|
||||
let text = typing.handleReturn()
|
||||
if !text.isEmpty { onInsert(text) }
|
||||
} label: {
|
||||
returnKeyLabel
|
||||
.foregroundStyle(returnKeyTextColor)
|
||||
.frame(
|
||||
width: TypingLayoutMetrics.bottomTrailingKeyWidth,
|
||||
height: TypingLayoutMetrics.bottomRowHeight
|
||||
)
|
||||
}
|
||||
.buttonStyle(returnKeyStyle)
|
||||
}
|
||||
}
|
||||
|
||||
@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 keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
|
||||
if label == "⌫" || label == "⇧" || label == "#+=" {
|
||||
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 nativeKeyStyle: NativeKeyboardKeyStyle {
|
||||
NativeKeyboardKeyStyle(
|
||||
fill: keyFill,
|
||||
pressedFill: keyPressedFill,
|
||||
border: palette.divider,
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius
|
||||
)
|
||||
}
|
||||
|
||||
private var returnKeyStyle: NativeKeyboardKeyStyle {
|
||||
switch state.returnKeyRole {
|
||||
case .newline:
|
||||
return nativeKeyStyle
|
||||
case .send:
|
||||
return NativeKeyboardKeyStyle(
|
||||
fill: sendKeyFill,
|
||||
pressedFill: sendKeyPressedFill,
|
||||
border: Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08),
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
// in `applyPresentationHeightOffset()`.
|
||||
//
|
||||
// ┌───────────────────────────────────────────┐
|
||||
// │ [polish] [中] ⚙ │ ← header band (top)
|
||||
// │ [OSG] 语音 中文 EN 译 │ ← header band (top)
|
||||
// │ (transcript preview) │
|
||||
// │ ┊ │
|
||||
// │ ◯ mic (centred) │ ← action cluster:
|
||||
@@ -22,17 +22,14 @@ import OSGKeyboardShared
|
||||
private enum KeyboardLayoutMetrics {
|
||||
static let micSize: CGFloat = 121
|
||||
static let micToButtonGap: CGFloat = 8
|
||||
static let bottomActionRowHeight: CGFloat = 48
|
||||
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
|
||||
static let bottomActionFixedWidth: CGFloat = 86
|
||||
static let bottomActionSpacing: CGFloat = Spacing.xs
|
||||
/// Gap between the top chip row and the transcript / hint line.
|
||||
/// Tightened (8 → 4) so the "点按说话" line hugs the chip row. The
|
||||
/// space reclaimed here and from `actionClusterTopGap` is added back
|
||||
/// into `actionClusterBottomGap`, keeping `totalHeight` constant while
|
||||
/// nudging the mic up toward the vertical centre.
|
||||
/// Gap between the top control row and the transcript / hint line.
|
||||
/// Four points keeps the "点按说话" line visually attached to the controls.
|
||||
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
|
||||
/// Outer inset for the bottom action row from screen edges (8 pt → 24 pt, +200%).
|
||||
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
|
||||
/// Match the typing key grid's outer edge.
|
||||
static let sideActionHorizontalInset: CGFloat = KeyboardChromeLayout.horizontalInset
|
||||
/// iPad: cap the content column. A full-width (~1180 pt) keyboard would
|
||||
/// park delete/return at the far screen edges and turn each cursor-drag
|
||||
/// pad into a ~450 pt runway — capping keeps the reach ergonomics of the
|
||||
@@ -40,41 +37,40 @@ private enum KeyboardLayoutMetrics {
|
||||
static let contentMaxWidth: CGFloat = 700
|
||||
|
||||
// MARK: - Content-driven keyboard height (single source of truth)
|
||||
static let outerPaddingTop: CGFloat = 2
|
||||
static let outerPaddingBottom: CGFloat = 1
|
||||
static let topBarHeight: CGFloat = 38
|
||||
static let outerPaddingTop: CGFloat = 4
|
||||
static let outerPaddingBottom: CGFloat = 4
|
||||
static let topBarHeight: CGFloat = KeyboardTopBarMetrics.height
|
||||
static let transcriptLineHeight: CGFloat = 22
|
||||
/// mic (121) + gap (8) + bottom row (48) = 177 pt
|
||||
/// mic (121) + gap (8) + bottom row (50) = 179 pt
|
||||
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
|
||||
/// Gap between transcript line and mic. Tightened (11.2 → 4) to pull
|
||||
/// the mic up; the reclaimed space moves to `actionClusterBottomGap`.
|
||||
static let actionClusterTopGap: CGFloat = Spacing.xs / 2
|
||||
/// Gap below the bottom action row.
|
||||
static let actionClusterBottomGap: CGFloat = 6
|
||||
/// Moves the action cluster down so its keys share the typing row's baseline.
|
||||
static let actionClusterTopGap: CGFloat = Spacing.xl
|
||||
/// The shared 4 pt outer padding is the complete bottom inset.
|
||||
static let actionClusterBottomGap: CGFloat = 0
|
||||
|
||||
static var headerBandHeight: CGFloat {
|
||||
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
|
||||
}
|
||||
|
||||
/// 2 + 64 + 4 + 177 + 15.2 + 1 = 263.2 pt (unchanged; the mic cluster
|
||||
/// just sits higher now that the top gaps moved to the bottom gap).
|
||||
static var totalHeight: CGFloat {
|
||||
outerPaddingTop
|
||||
+ headerBandHeight
|
||||
+ actionClusterTopGap
|
||||
+ actionClusterHeight
|
||||
+ actionClusterBottomGap
|
||||
+ outerPaddingBottom
|
||||
}
|
||||
/// 4 + 70 + 24 + 179 + 0 + 4 = 281 pt, matching Chinese / English.
|
||||
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
|
||||
}
|
||||
|
||||
public struct KeyboardRootView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@ObservedObject var state: State
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
let onInsert: (String) -> Void
|
||||
|
||||
public init(state: KeyboardViewController.State) {
|
||||
public init(
|
||||
state: KeyboardViewController.State,
|
||||
typing: TypingSessionController = TypingSessionController(),
|
||||
onInsert: @escaping (String) -> Void = { _ in }
|
||||
) {
|
||||
self.state = state
|
||||
self.typing = typing
|
||||
self.onInsert = onInsert
|
||||
}
|
||||
|
||||
/// Content-driven keyboard height; mirrored on `UIInputViewController.view`
|
||||
@@ -136,7 +132,7 @@ public struct KeyboardRootView: View {
|
||||
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
|
||||
}
|
||||
|
||||
/// Top chip row + transcript / hint line.
|
||||
/// Top brand / mode row + transcript / hint line.
|
||||
private var headerBand: some View {
|
||||
VStack(spacing: KeyboardLayoutMetrics.topBarToTranscriptSpacing) {
|
||||
topBar
|
||||
@@ -158,35 +154,18 @@ public struct KeyboardRootView: View {
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if state.isLocalEngine {
|
||||
LocalEngineChip()
|
||||
} else {
|
||||
CloudEngineChip()
|
||||
}
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
// Engine controls remain available in the host app.
|
||||
// App context is auto-detected on each mic press — no UI.
|
||||
if state.isTranslationChipVisible {
|
||||
TranslationChip(
|
||||
palette: palette,
|
||||
targetLocaleId: state.translationTargetLocaleId,
|
||||
onSelect: state.setTranslationTargetLocaleId
|
||||
)
|
||||
// Decouple the open picker from the keyboard's 1 Hz App
|
||||
// Group poll so scrolling doesn't reset / dismiss it.
|
||||
.equatable()
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Button(action: state.openSettings) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(palette.surface, in: Circle())
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.openSettingsA11y"))
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
|
||||
}
|
||||
|
||||
// MARK: - Action cluster
|
||||
@@ -273,7 +252,12 @@ public struct KeyboardRootView: View {
|
||||
|
||||
private func bottomReturnButton(disabled: Bool) -> some View {
|
||||
let title = ExtL10n.string(state.returnKeyRole.titleKey)
|
||||
return RectangularToolbarButton(title: title, label: title, disabled: disabled) {
|
||||
return RectangularToolbarButton(
|
||||
title: title,
|
||||
label: title,
|
||||
disabled: disabled,
|
||||
isSend: state.returnKeyRole == .send
|
||||
) {
|
||||
state.insertNewline()
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// KeyboardTopControls.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Shared top-right input switcher used by both voice and typing surfaces.
|
||||
// The same footprint is replaced by candidates while Chinese is composing.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum KeyboardTopBarMetrics {
|
||||
static let height: CGFloat = 44
|
||||
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 logoWidth: CGFloat = logoHeight * 952 / 291
|
||||
}
|
||||
|
||||
struct KeyboardBrandLogo: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image("OSGLogoWide")
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.scaledToFit()
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
|
||||
.frame(
|
||||
width: KeyboardTopBarMetrics.logoWidth,
|
||||
height: KeyboardTopBarMetrics.logoHeight
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.buttonStyle(BrandLogoPressStyle())
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.onboarding.api.openHostApp"))
|
||||
}
|
||||
}
|
||||
|
||||
private enum KeyboardInputTab: CaseIterable {
|
||||
case voice
|
||||
case chinese
|
||||
case english
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .voice: return "语音"
|
||||
case .chinese: return "中文"
|
||||
case .english: return "EN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyboardTopControls: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@ObservedObject var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
let palette: ThemePalette
|
||||
let onInsert: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(KeyboardInputTab.allCases, id: \.self) { tab in
|
||||
Button {
|
||||
select(tab)
|
||||
} label: {
|
||||
Text(tab.title)
|
||||
.font(.system(size: 12, weight: isSelected(tab) ? .semibold : .medium))
|
||||
.foregroundStyle(
|
||||
isSelected(tab) ? palette.textPrimary : palette.textSecondary
|
||||
)
|
||||
.frame(width: tab == .english ? 34 : 42, height: 30)
|
||||
.background {
|
||||
if isSelected(tab) {
|
||||
Capsule()
|
||||
.fill(selectedFill)
|
||||
.shadow(
|
||||
color: Color.black.opacity(colorScheme == .dark ? 0.22 : 0.10),
|
||||
radius: 1.5,
|
||||
y: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
|
||||
.disabled(tab != .voice && !state.canEnterTypingSurface)
|
||||
.opacity(tab != .voice && !state.canEnterTypingSurface ? 0.42 : 1)
|
||||
.accessibilityLabel(accessibilityLabel(for: tab))
|
||||
.accessibilityAddTraits(isSelected(tab) ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
.background(trackFill, in: Capsule())
|
||||
|
||||
KeyboardTranslationMenuButton(
|
||||
palette: palette,
|
||||
targetLocaleId: state.translationTargetLocaleId,
|
||||
onSelect: state.setTranslationTargetLocaleId
|
||||
)
|
||||
// Decouple the open picker from the keyboard's 1 Hz App Group
|
||||
// poll so scrolling does not reset or dismiss the menu.
|
||||
.equatable()
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedFill: Color {
|
||||
colorScheme == .dark ? Color(white: 0.38) : .white
|
||||
}
|
||||
|
||||
private var trackFill: Color {
|
||||
colorScheme == .dark ? Color(white: 0.18) : Color.black.opacity(0.08)
|
||||
}
|
||||
|
||||
private var pressedFill: Color {
|
||||
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
|
||||
}
|
||||
|
||||
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
|
||||
switch tab {
|
||||
case .voice:
|
||||
return state.surface == .voice
|
||||
case .chinese:
|
||||
return state.surface == .typing && typing.language == .chinese
|
||||
case .english:
|
||||
return state.surface == .typing && typing.language == .english
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ tab: KeyboardInputTab) {
|
||||
switch tab {
|
||||
case .voice:
|
||||
state.setSurface(.voice)
|
||||
case .chinese:
|
||||
let raw = typing.setLanguage(.chinese)
|
||||
if !raw.isEmpty { onInsert(raw) }
|
||||
state.setSurface(.typing)
|
||||
case .english:
|
||||
let raw = typing.setLanguage(.english)
|
||||
if !raw.isEmpty { onInsert(raw) }
|
||||
state.setSurface(.typing)
|
||||
}
|
||||
}
|
||||
|
||||
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
|
||||
switch tab {
|
||||
case .voice: return "切换到语音输入"
|
||||
case .chinese: return "切换到中文输入"
|
||||
case .english: return "切换到英文输入"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeyboardTranslationMenuButton: View, Equatable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let palette: ThemePalette
|
||||
let targetLocaleId: String
|
||||
let onSelect: (String) -> Void
|
||||
|
||||
nonisolated static func == (
|
||||
lhs: KeyboardTranslationMenuButton,
|
||||
rhs: KeyboardTranslationMenuButton
|
||||
) -> Bool {
|
||||
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
|
||||
}
|
||||
|
||||
private var isEnabled: Bool {
|
||||
targetLocaleId != TranslationLanguageCatalog.offLocaleId
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Button {
|
||||
onSelect(language.id)
|
||||
} label: {
|
||||
if language.id == targetLocaleId {
|
||||
Label(displayLabel(for: language), systemImage: "checkmark")
|
||||
} else {
|
||||
Text(displayLabel(for: language))
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(isEnabled ? palette.accent : palette.textSecondary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(buttonFill, in: Circle())
|
||||
.overlay(Circle().stroke(buttonStroke, lineWidth: 0.5))
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
|
||||
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
|
||||
}
|
||||
|
||||
private var buttonFill: Color {
|
||||
if isEnabled {
|
||||
return palette.accent.opacity(colorScheme == .dark ? 0.28 : 0.16)
|
||||
}
|
||||
return colorScheme == .dark ? Color(white: 0.30) : .white
|
||||
}
|
||||
|
||||
private var buttonStroke: Color {
|
||||
isEnabled ? palette.accent.opacity(0.35) : palette.divider
|
||||
}
|
||||
|
||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||
return SharedL10n.string("keyboard.translation.offMenu")
|
||||
}
|
||||
return language.nativeName
|
||||
}
|
||||
}
|
||||
|
||||
private struct BrandLogoPressStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.opacity(configuration.isPressed ? 0.62 : 1)
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TopControlPressStyle: ButtonStyle {
|
||||
let pressedFill: Color
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.background(configuration.isPressed ? pressedFill.opacity(0.55) : .clear)
|
||||
.clipShape(Capsule())
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1)
|
||||
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// NativeKeyboardKeyStyle.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Shared native-like key surface used by voice and typing action rows.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum NativeKeyboardKeyColors {
|
||||
static func fill(for colorScheme: ColorScheme) -> Color {
|
||||
colorScheme == .dark ? Color(white: 0.32) : .white
|
||||
}
|
||||
|
||||
static func pressedFill(for colorScheme: ColorScheme) -> Color {
|
||||
colorScheme == .dark ? Color(white: 0.23) : Color(white: 0.84)
|
||||
}
|
||||
|
||||
static func text(for colorScheme: ColorScheme) -> Color {
|
||||
colorScheme == .dark ? .white : Color(red: 0.06, green: 0.06, blue: 0.08)
|
||||
}
|
||||
|
||||
/// Adaptive brand green: brighter in dark mode and deeper in light mode.
|
||||
static func sendFill(for colorScheme: ColorScheme) -> Color {
|
||||
colorScheme == .dark
|
||||
? Color(red: 0.286, green: 0.725, blue: 0.416)
|
||||
: Color(red: 0.196, green: 0.549, blue: 0.298)
|
||||
}
|
||||
|
||||
static func sendPressedFill(for colorScheme: ColorScheme) -> Color {
|
||||
colorScheme == .dark
|
||||
? Color(red: 0.227, green: 0.627, blue: 0.353)
|
||||
: Color(red: 0.157, green: 0.447, blue: 0.247)
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeKeyboardKeySurface<Content: View>: View {
|
||||
let isPressed: Bool
|
||||
let fill: Color
|
||||
let pressedFill: Color
|
||||
let border: Color
|
||||
let cornerRadius: CGFloat
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.fill(isPressed ? pressedFill : fill)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.stroke(border, 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)
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeKeyboardKeyStyle: ButtonStyle {
|
||||
let fill: Color
|
||||
let pressedFill: Color
|
||||
let border: Color
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
NativeKeyboardKeySurface(
|
||||
isPressed: configuration.isPressed,
|
||||
fill: fill,
|
||||
pressedFill: pressedFill,
|
||||
border: border,
|
||||
cornerRadius: cornerRadius
|
||||
) {
|
||||
configuration.label
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,13 @@ import OSGKeyboardShared
|
||||
private enum ToolbarButtonMetrics {
|
||||
static let iconSize: CGFloat = 14
|
||||
static let titleSize: CGFloat = 16
|
||||
static let cornerRadius: CGFloat = 12
|
||||
static let cornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
|
||||
static let spaceBarCapsuleWidth: CGFloat = 31
|
||||
static let pressScale: CGFloat = 0.94
|
||||
static let pressOverlayOpacity: CGFloat = 0.18
|
||||
}
|
||||
|
||||
private enum ToolbarKeyEmphasis {
|
||||
case standard
|
||||
case send
|
||||
}
|
||||
|
||||
// MARK: - Press styling
|
||||
@@ -25,31 +28,45 @@ private struct ToolbarKeySurface<Content: View>: View {
|
||||
|
||||
let isPressed: Bool
|
||||
let cornerRadius: CGFloat
|
||||
let emphasis: ToolbarKeyEmphasis
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(buttonFill, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
}
|
||||
.overlay {
|
||||
if isPressed {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity))
|
||||
}
|
||||
}
|
||||
.scaleEffect(isPressed ? ToolbarButtonMetrics.pressScale : 1)
|
||||
.animation(.easeOut(duration: 0.1), value: isPressed)
|
||||
NativeKeyboardKeySurface(
|
||||
isPressed: isPressed,
|
||||
fill: buttonFill,
|
||||
pressedFill: buttonPressedFill,
|
||||
border: buttonBorder,
|
||||
cornerRadius: cornerRadius,
|
||||
content: content
|
||||
)
|
||||
}
|
||||
|
||||
private var buttonFill: Color {
|
||||
let base = colorScheme == .dark
|
||||
? Color(red: 0.20, green: 0.20, blue: 0.22)
|
||||
: palette.surfaceElevated
|
||||
return isPressed ? base.opacity(0.82) : base
|
||||
switch emphasis {
|
||||
case .standard:
|
||||
return NativeKeyboardKeyColors.fill(for: colorScheme)
|
||||
case .send:
|
||||
return NativeKeyboardKeyColors.sendFill(for: colorScheme)
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonPressedFill: Color {
|
||||
switch emphasis {
|
||||
case .standard:
|
||||
return NativeKeyboardKeyColors.pressedFill(for: colorScheme)
|
||||
case .send:
|
||||
return NativeKeyboardKeyColors.sendPressedFill(for: colorScheme)
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonBorder: Color {
|
||||
switch emphasis {
|
||||
case .standard:
|
||||
return palette.divider
|
||||
case .send:
|
||||
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +74,7 @@ private struct ToolbarKeySurface<Content: View>: View {
|
||||
|
||||
/// Tap deletes once; hold repeats with tiered acceleration after 5 s.
|
||||
struct RepeatingDeleteButton: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let disabled: Bool
|
||||
let action: () -> Void
|
||||
@@ -73,10 +90,14 @@ struct RepeatingDeleteButton: View {
|
||||
private let accelTier4: TimeInterval = 0.015
|
||||
|
||||
var body: some View {
|
||||
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
|
||||
ToolbarKeySurface(
|
||||
isPressed: isPressing,
|
||||
cornerRadius: ToolbarButtonMetrics.cornerRadius,
|
||||
emphasis: .standard
|
||||
) {
|
||||
Image(systemName: "delete.left")
|
||||
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.gesture(pressGesture)
|
||||
@@ -135,13 +156,14 @@ struct RepeatingDeleteButton: View {
|
||||
// MARK: - Rectangular toolbar button
|
||||
|
||||
struct RectangularToolbarButton: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let systemName: String?
|
||||
let spaceStyle: Bool
|
||||
let title: String?
|
||||
let label: String
|
||||
let disabled: Bool
|
||||
let isSend: Bool
|
||||
let action: () -> Void
|
||||
|
||||
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
|
||||
@@ -150,14 +172,22 @@ struct RectangularToolbarButton: View {
|
||||
self.title = nil
|
||||
self.label = label
|
||||
self.disabled = disabled
|
||||
self.isSend = false
|
||||
self.action = action
|
||||
}
|
||||
|
||||
init(title: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
|
||||
init(
|
||||
title: String,
|
||||
label: String,
|
||||
disabled: Bool = false,
|
||||
isSend: Bool = false,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.systemName = nil
|
||||
self.spaceStyle = false
|
||||
self.label = label
|
||||
self.disabled = disabled
|
||||
self.isSend = isSend
|
||||
self.action = action
|
||||
self.title = title
|
||||
}
|
||||
@@ -168,25 +198,30 @@ struct RectangularToolbarButton: View {
|
||||
self.title = nil
|
||||
self.label = label
|
||||
self.disabled = disabled
|
||||
self.isSend = false
|
||||
self.action = action
|
||||
}
|
||||
|
||||
@State private var isPressing = false
|
||||
|
||||
var body: some View {
|
||||
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
|
||||
ToolbarKeySurface(
|
||||
isPressed: isPressing,
|
||||
cornerRadius: ToolbarButtonMetrics.cornerRadius,
|
||||
emphasis: isSend ? .send : .standard
|
||||
) {
|
||||
if spaceStyle {
|
||||
Capsule()
|
||||
.fill(palette.textPrimary)
|
||||
.fill(buttonForeground)
|
||||
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
|
||||
} else if let systemName {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.foregroundStyle(buttonForeground)
|
||||
} else if let title {
|
||||
Text(title)
|
||||
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.foregroundStyle(buttonForeground)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@@ -197,6 +232,10 @@ struct RectangularToolbarButton: View {
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
|
||||
private var buttonForeground: Color {
|
||||
isSend ? .white : NativeKeyboardKeyColors.text(for: colorScheme)
|
||||
}
|
||||
|
||||
// 按下即响、按下即执行,与系统键盘保持一致(Button 默认松手才触发)。
|
||||
private var pressGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// KeyboardSurfaceStateTests.swift
|
||||
// OSGKeyboard · Ext unit tests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
final class KeyboardSurfaceStateTests: XCTestCase {
|
||||
func testRecordingLocksTyping() {
|
||||
let state = KeyboardState()
|
||||
state.phase = .idle
|
||||
XCTAssertTrue(state.canEnterTypingSurface)
|
||||
state.phase = .recording
|
||||
XCTAssertTrue(state.locksTypingSurface)
|
||||
XCTAssertFalse(state.canEnterTypingSurface)
|
||||
}
|
||||
|
||||
func testStandardLayoutHasQwertyTopRow() {
|
||||
let layout = StandardTypingLayout()
|
||||
let rows = layout.rows(for: .letters, shiftActive: false)
|
||||
XCTAssertEqual(rows.first, ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"])
|
||||
let shifted = layout.rows(for: .letters, shiftActive: true)
|
||||
XCTAssertEqual(shifted.first?.first, "Q")
|
||||
}
|
||||
|
||||
func testVoiceAndTypingChromeShareStableDimensions() {
|
||||
XCTAssertEqual(KeyboardChromeLayout.totalHeight, 281)
|
||||
XCTAssertEqual(KeyboardChromeLayout.actionKeyHeight, 50)
|
||||
XCTAssertEqual(KeyboardChromeLayout.actionKeyCornerRadius, 10)
|
||||
XCTAssertEqual(KeyboardChromeLayout.horizontalInset, 8)
|
||||
}
|
||||
|
||||
func testSharedCapsuleCanSelectSpecificTypingLanguage() {
|
||||
let typing = TypingSessionController()
|
||||
XCTAssertEqual(typing.language, .chinese)
|
||||
|
||||
XCTAssertEqual(typing.setLanguage(.english), "")
|
||||
XCTAssertEqual(typing.language, .english)
|
||||
|
||||
XCTAssertEqual(typing.setLanguage(.chinese), "")
|
||||
XCTAssertEqual(typing.language, .chinese)
|
||||
}
|
||||
|
||||
func testShiftStateProducesUppercaseKeyRows() {
|
||||
let typing = TypingSessionController()
|
||||
XCTAssertFalse(typing.shiftActive)
|
||||
|
||||
_ = typing.handleKey("⇧")
|
||||
|
||||
XCTAssertTrue(typing.shiftActive)
|
||||
XCTAssertEqual(typing.keyRows.first?.first, "Q")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// LibrimeIntegrationTests.swift
|
||||
// OSGKeyboard · Ext unit tests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class LibrimeIntegrationTests: XCTestCase {
|
||||
func testFullPinyinAndBothDoublePinyinSchemasProducePhraseCandidates() throws {
|
||||
let fileManager = FileManager.default
|
||||
let root = fileManager.temporaryDirectory
|
||||
.appendingPathComponent("OSGRimeTests-\(UUID().uuidString)", isDirectory: true)
|
||||
let shared = root.appendingPathComponent("SharedSupport", isDirectory: true)
|
||||
let user = root.appendingPathComponent("UserData", isDirectory: true)
|
||||
defer { try? fileManager.removeItem(at: root) }
|
||||
try fileManager.createDirectory(at: shared, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: user, withIntermediateDirectories: true)
|
||||
|
||||
let bundle = try XCTUnwrap(Bundle(identifier: "com.osgkeyboard.ios.shared"))
|
||||
let dictionary = try XCTUnwrap(
|
||||
bundle.url(forResource: "osg_pinyin.dict", withExtension: "yaml")
|
||||
)
|
||||
try fileManager.copyItem(
|
||||
at: dictionary,
|
||||
to: shared.appendingPathComponent("osg_pinyin.dict.yaml")
|
||||
)
|
||||
try RimeSchemaGenerator.defaultConfiguration().write(
|
||||
to: shared.appendingPathComponent("default.yaml"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
for schema in TypingInputSchema.allCases {
|
||||
try RimeSchemaGenerator.schema(for: schema, fuzzyPairs: []).write(
|
||||
to: shared.appendingPathComponent("\(schema.rawValue).schema.yaml"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
}
|
||||
|
||||
let deployer = OSGRimeBridge(
|
||||
sharedDataDirectory: shared.path,
|
||||
userDataDirectory: user.path,
|
||||
distributionVersion: "tests"
|
||||
)
|
||||
try deployer.deploy(withFullCheck: true)
|
||||
deployer.finalizeRuntime()
|
||||
|
||||
let bridge = OSGRimeBridge(
|
||||
sharedDataDirectory: shared.path,
|
||||
userDataDirectory: user.path,
|
||||
distributionVersion: "tests"
|
||||
)
|
||||
try bridge.start()
|
||||
|
||||
let vectors: [(TypingInputSchema, String)] = [
|
||||
(.fullPinyin, "nihao"),
|
||||
(.microsoftDoublePinyin, "nihk"),
|
||||
(.sogouDoublePinyin, "nihk")
|
||||
]
|
||||
|
||||
for (schema, keys) in vectors {
|
||||
bridge.clearComposition()
|
||||
XCTAssertTrue(bridge.selectSchema(schema.rawValue), schema.displayName)
|
||||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
for scalar in keys.utf8 {
|
||||
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
|
||||
}
|
||||
let snapshot = bridge.snapshot(withCandidateLimit: 100)
|
||||
XCTAssertLessThan(
|
||||
ProcessInfo.processInfo.systemUptime - startedAt,
|
||||
0.15,
|
||||
"\(schema.displayName) first candidates exceeded 150 ms"
|
||||
)
|
||||
XCTAssertFalse(snapshot.preedit.isEmpty, schema.displayName)
|
||||
XCTAssertTrue(
|
||||
snapshot.candidates.contains(where: { $0.text == "你好" }),
|
||||
"\(schema.displayName) candidates: \(snapshot.candidates.map(\.text).prefix(20))"
|
||||
)
|
||||
if schema == .fullPinyin,
|
||||
let helloIndex = snapshot.candidates.firstIndex(where: { $0.text == "你好" }) {
|
||||
XCTAssertTrue(bridge.selectCandidate(at: helloIndex))
|
||||
XCTAssertEqual(
|
||||
bridge.snapshot(withCandidateLimit: 10).commitText,
|
||||
"你好"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
|
||||
let phraseVectors = [
|
||||
("rengongzhineng", "人工智能"),
|
||||
("yuyinshuru", "语音输入"),
|
||||
("zhonghuarenmingongheguo", "中华人民共和国")
|
||||
]
|
||||
for (keys, expected) in phraseVectors {
|
||||
bridge.clearComposition()
|
||||
for scalar in keys.utf8 {
|
||||
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
|
||||
}
|
||||
let snapshot = bridge.snapshot(withCandidateLimit: 100)
|
||||
XCTAssertTrue(
|
||||
snapshot.candidates.contains(where: { $0.text == expected }),
|
||||
"\(keys) candidates: \(snapshot.candidates.map(\.text).prefix(20))"
|
||||
)
|
||||
}
|
||||
bridge.finalizeRuntime()
|
||||
|
||||
let userFiles = fileManager.enumerator(atPath: user.path)?
|
||||
.compactMap { $0 as? String } ?? []
|
||||
XCTAssertTrue(userFiles.contains(where: { $0.contains("userdb") }))
|
||||
|
||||
// Redeploy with one fuzzy pair and verify it affects actual Rime
|
||||
// candidates rather than just generated YAML.
|
||||
for schema in TypingInputSchema.allCases {
|
||||
try RimeSchemaGenerator.schema(for: schema, fuzzyPairs: [.nL]).write(
|
||||
to: shared.appendingPathComponent("\(schema.rawValue).schema.yaml"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
}
|
||||
let fuzzyDeployer = OSGRimeBridge(
|
||||
sharedDataDirectory: shared.path,
|
||||
userDataDirectory: user.path,
|
||||
distributionVersion: "tests-fuzzy"
|
||||
)
|
||||
try fuzzyDeployer.deploy(withFullCheck: true)
|
||||
fuzzyDeployer.finalizeRuntime()
|
||||
|
||||
let fuzzyBridge = OSGRimeBridge(
|
||||
sharedDataDirectory: shared.path,
|
||||
userDataDirectory: user.path,
|
||||
distributionVersion: "tests-fuzzy"
|
||||
)
|
||||
try fuzzyBridge.start()
|
||||
XCTAssertTrue(fuzzyBridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
|
||||
for scalar in "lihao".utf8 {
|
||||
XCTAssertTrue(fuzzyBridge.processKeyCode(Int32(scalar), modifiers: 0))
|
||||
}
|
||||
let fuzzySnapshot = fuzzyBridge.snapshot(withCandidateLimit: 100)
|
||||
XCTAssertTrue(
|
||||
fuzzySnapshot.candidates.contains(where: { $0.text == "你好" }),
|
||||
"n/l fuzzy candidates: \(fuzzySnapshot.candidates.map(\.text).prefix(20))"
|
||||
)
|
||||
fuzzyBridge.finalizeRuntime()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// RimeSchemaGeneratorTests.swift
|
||||
// OSGKeyboard · Ext unit tests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class RimeSchemaGeneratorTests: XCTestCase {
|
||||
func testFuzzyRulesDefaultToOff() {
|
||||
XCTAssertTrue(RimeSchemaGenerator.fuzzyRules([]).isEmpty)
|
||||
}
|
||||
|
||||
func testFuzzyRulesAreOptInAndSymmetric() {
|
||||
let rules = RimeSchemaGenerator.fuzzyRules([.nL, .anAng])
|
||||
XCTAssertTrue(rules.contains("derive/^n/l/"))
|
||||
XCTAssertTrue(rules.contains("derive/^l/n/"))
|
||||
XCTAssertTrue(rules.contains("derive/ang$/an/"))
|
||||
XCTAssertTrue(rules.contains("derive/an$/ang/"))
|
||||
XCTAssertFalse(rules.contains { $0.contains("eng") })
|
||||
}
|
||||
|
||||
func testMicrosoftAndSogouExposeIngSemicolonMapping() {
|
||||
for schema in [
|
||||
TypingInputSchema.microsoftDoublePinyin,
|
||||
TypingInputSchema.sogouDoublePinyin
|
||||
] {
|
||||
let yaml = RimeSchemaGenerator.schema(for: schema, fuzzyPairs: [])
|
||||
XCTAssertTrue(yaml.contains("xform/ing$/;/"))
|
||||
XCTAssertTrue(yaml.contains("xform/^sh/U/"))
|
||||
XCTAssertTrue(yaml.contains("xform/^ch/I/"))
|
||||
XCTAssertTrue(yaml.contains("xform/^zh/V/"))
|
||||
}
|
||||
}
|
||||
|
||||
func testFuzzyRulesPrecedeDoublePinyinTransforms() throws {
|
||||
let yaml = RimeSchemaGenerator.schema(
|
||||
for: .microsoftDoublePinyin,
|
||||
fuzzyPairs: [.nL]
|
||||
)
|
||||
let fuzzy = try XCTUnwrap(yaml.range(of: "derive/^n/l/"))
|
||||
let transform = try XCTUnwrap(yaml.range(of: "xform/iu$/Q/"))
|
||||
XCTAssertLessThan(fuzzy.lowerBound, transform.lowerBound)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTypingConfigurationDefaultsToFullPinyinWithoutFuzzyPairs() {
|
||||
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let configuration = TypingInputConfiguration(defaults: defaults)
|
||||
XCTAssertEqual(configuration.schema, .fullPinyin)
|
||||
XCTAssertTrue(configuration.fuzzyPairs.isEmpty)
|
||||
XCTAssertFalse(configuration.defaultToTyping)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDefaultTypingPreferencePersistsAndDefaultsToOff() {
|
||||
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
XCTAssertFalse(TypingInputConfiguration.prefersTypingOnOpen(defaults: defaults))
|
||||
|
||||
let configuration = TypingInputConfiguration(defaults: defaults)
|
||||
configuration.defaultToTyping = true
|
||||
|
||||
XCTAssertTrue(TypingInputConfiguration.prefersTypingOnOpen(defaults: defaults))
|
||||
XCTAssertTrue(TypingInputConfiguration(defaults: defaults).defaultToTyping)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ struct MacOpenSourceLicensesView: View {
|
||||
let uiLanguage: AppUILanguage
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private let entries = OpenSourceLicenseCatalog.entries(for: .macOS)
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
@@ -44,7 +46,7 @@ struct MacOpenSourceLicensesView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(OpenSourceLicenseCatalog.entries) { entry in
|
||||
ForEach(entries) { entry in
|
||||
NavigationLink {
|
||||
MacOpenSourceLicenseDetailView(entry: entry, uiLanguage: uiLanguage)
|
||||
} label: {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// KeyboardChromeLayout.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Cross-surface dimensions that must stay identical in voice and typing modes.
|
||||
|
||||
import CoreGraphics
|
||||
|
||||
public enum KeyboardChromeLayout {
|
||||
public static let totalHeight: CGFloat = 281
|
||||
public static let actionKeyHeight: CGFloat = 50
|
||||
public static let actionKeyCornerRadius: CGFloat = 10
|
||||
public static let horizontalInset: CGFloat = 8
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// TypingInputConfiguration.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// App Group-backed Chinese input settings shared by the host app and
|
||||
// keyboard extension. Fuzzy pairs are opt-in to avoid noisy candidates.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
public enum TypingInputSchema: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case fullPinyin = "osg_pinyin"
|
||||
case microsoftDoublePinyin = "osg_double_pinyin_mspy"
|
||||
case sogouDoublePinyin = "osg_double_pinyin_sogou"
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var shortLabel: String {
|
||||
switch self {
|
||||
case .fullPinyin: return "全"
|
||||
case .microsoftDoublePinyin: return "微"
|
||||
case .sogouDoublePinyin: return "搜"
|
||||
}
|
||||
}
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .fullPinyin: return "全拼"
|
||||
case .microsoftDoublePinyin: return "微软双拼"
|
||||
case .sogouDoublePinyin: return "搜狗双拼"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum PinyinFuzzyPair: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case zhZ
|
||||
case chC
|
||||
case shS
|
||||
case nL
|
||||
case fH
|
||||
case anAng
|
||||
case enEng
|
||||
case inIng
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .zhZ: return "zh ↔ z"
|
||||
case .chC: return "ch ↔ c"
|
||||
case .shS: return "sh ↔ s"
|
||||
case .nL: return "n ↔ l"
|
||||
case .fH: return "f ↔ h"
|
||||
case .anAng: return "an ↔ ang"
|
||||
case .enEng: return "en ↔ eng"
|
||||
case .inIng: return "in ↔ ing"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct TypingInputConfigurationSnapshot: Equatable, Sendable {
|
||||
public let schema: TypingInputSchema
|
||||
public let fuzzyPairs: Set<PinyinFuzzyPair>
|
||||
|
||||
public init(schema: TypingInputSchema, fuzzyPairs: Set<PinyinFuzzyPair>) {
|
||||
self.schema = schema
|
||||
self.fuzzyPairs = fuzzyPairs
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class TypingInputConfiguration: ObservableObject {
|
||||
public static let shared = TypingInputConfiguration()
|
||||
|
||||
private enum Key {
|
||||
static let schema = "typing.input.schema"
|
||||
static let fuzzyPairs = "typing.input.fuzzyPairs"
|
||||
static let defaultToTyping = "typing.input.defaultToTyping"
|
||||
static let resourceVersion = "typing.rime.resourceVersion"
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private var isHydrating = true
|
||||
|
||||
@Published public var schema: TypingInputSchema {
|
||||
didSet { persistIfReady() }
|
||||
}
|
||||
|
||||
@Published public var fuzzyPairs: Set<PinyinFuzzyPair> {
|
||||
didSet { persistIfReady() }
|
||||
}
|
||||
|
||||
/// Selects the text keyboard whenever the extension becomes visible.
|
||||
@Published public var defaultToTyping: Bool {
|
||||
didSet { persistIfReady() }
|
||||
}
|
||||
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
self.defaults = defaults ?? AppGroup.defaults
|
||||
let schemaId = self.defaults.string(forKey: Key.schema) ?? ""
|
||||
schema = TypingInputSchema(rawValue: schemaId) ?? .fullPinyin
|
||||
let fuzzyIds = self.defaults.stringArray(forKey: Key.fuzzyPairs) ?? []
|
||||
fuzzyPairs = Set(fuzzyIds.compactMap(PinyinFuzzyPair.init(rawValue:)))
|
||||
defaultToTyping = self.defaults.bool(forKey: Key.defaultToTyping)
|
||||
isHydrating = false
|
||||
}
|
||||
|
||||
public var snapshot: TypingInputConfigurationSnapshot {
|
||||
TypingInputConfigurationSnapshot(schema: schema, fuzzyPairs: fuzzyPairs)
|
||||
}
|
||||
|
||||
public func setFuzzyPair(_ pair: PinyinFuzzyPair, enabled: Bool) {
|
||||
if enabled {
|
||||
fuzzyPairs.insert(pair)
|
||||
} else {
|
||||
fuzzyPairs.remove(pair)
|
||||
}
|
||||
}
|
||||
|
||||
public func reload() {
|
||||
isHydrating = true
|
||||
schema = TypingInputSchema(rawValue: defaults.string(forKey: Key.schema) ?? "")
|
||||
?? .fullPinyin
|
||||
let fuzzyIds = defaults.stringArray(forKey: Key.fuzzyPairs) ?? []
|
||||
fuzzyPairs = Set(fuzzyIds.compactMap(PinyinFuzzyPair.init(rawValue:)))
|
||||
defaultToTyping = defaults.bool(forKey: Key.defaultToTyping)
|
||||
isHydrating = false
|
||||
}
|
||||
|
||||
nonisolated public static func prefersTypingOnOpen(
|
||||
defaults: UserDefaults? = nil
|
||||
) -> Bool {
|
||||
(defaults ?? AppGroup.defaultsIfAvailable)?.bool(forKey: Key.defaultToTyping) ?? false
|
||||
}
|
||||
|
||||
nonisolated public static func installedResourceVersion(
|
||||
defaults: UserDefaults? = nil
|
||||
) -> String? {
|
||||
(defaults ?? AppGroup.defaultsIfAvailable)?.string(forKey: Key.resourceVersion)
|
||||
}
|
||||
|
||||
nonisolated public static func setInstalledResourceVersion(
|
||||
_ value: String,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
(defaults ?? AppGroup.defaultsIfAvailable)?.set(value, forKey: Key.resourceVersion)
|
||||
}
|
||||
|
||||
private func persistIfReady() {
|
||||
guard !isHydrating else { return }
|
||||
defaults.set(schema.rawValue, forKey: Key.schema)
|
||||
defaults.set(fuzzyPairs.map(\.rawValue).sorted(), forKey: Key.fuzzyPairs)
|
||||
defaults.set(defaultToTyping, forKey: Key.defaultToTyping)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013 Sun Junyi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 mozillazg
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 mozillazg
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,32 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, librime-xcframework contributors
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived
|
||||
from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,29 @@
|
||||
Typing keyboard third-party notices
|
||||
===================================
|
||||
|
||||
Runtime
|
||||
-------
|
||||
librime-xcframework 1.17.0-pack.1 and librime 1.17.0 (BSD-3-Clause).
|
||||
See LICENSE.txt, THIRD_PARTY_NOTICES.md and third-party-notices.zip bundled
|
||||
with this file for full binary dependency license texts.
|
||||
|
||||
Dictionary data
|
||||
---------------
|
||||
- rime-pinyin-simp @ 0c6861ef7420ee780270ca6d993d18d4101049d0
|
||||
Apache License 2.0
|
||||
- fxsjy/jieba @ 67fa2e36e72f69d9134b8a1037b83fbb070b9775
|
||||
MIT License
|
||||
- mozillazg/phrase-pinyin-data @ cee0ed6e6e4898580cafd2bd5e3723e20b214aa0
|
||||
MIT License
|
||||
- mozillazg/pinyin-data @ 923b108dc5d45dee061324c011b478fb649f8b73
|
||||
MIT License
|
||||
|
||||
The generated manifest.json records exact source URLs and SHA-256 values.
|
||||
OSGKeyboard's schema and transformation code is project-owned.
|
||||
|
||||
Explicitly not distributed
|
||||
--------------------------
|
||||
rime-ice, rime-double-pinyin, rime-luna-pinyin, rime-essay and KeyboardKit Pro.
|
||||
|
||||
The Apache-2.0 and MIT license texts are available from the source links above;
|
||||
all required copyright and permission notices must remain with distributions.
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"sources": {
|
||||
"pinyin_simp": {
|
||||
"license": "Apache-2.0",
|
||||
"commit": "0c6861ef7420ee780270ca6d993d18d4101049d0",
|
||||
"url": "https://raw.githubusercontent.com/rime/rime-pinyin-simp/0c6861ef7420ee780270ca6d993d18d4101049d0/pinyin_simp.dict.yaml",
|
||||
"sha256": "e341598343a0f0f2035bb1aafc34a7f3bb7887deeecb3f60796262aaa2983e6b"
|
||||
},
|
||||
"jieba": {
|
||||
"license": "MIT",
|
||||
"commit": "67fa2e36e72f69d9134b8a1037b83fbb070b9775",
|
||||
"url": "https://raw.githubusercontent.com/fxsjy/jieba/67fa2e36e72f69d9134b8a1037b83fbb070b9775/jieba/dict.txt",
|
||||
"sha256": "7197c3211ddd98962b036cdf40324d1ea2bfaa12bd028e68faa70111a88e12a8"
|
||||
},
|
||||
"phrase_pinyin": {
|
||||
"license": "MIT",
|
||||
"commit": "cee0ed6e6e4898580cafd2bd5e3723e20b214aa0",
|
||||
"url": "https://raw.githubusercontent.com/mozillazg/phrase-pinyin-data/cee0ed6e6e4898580cafd2bd5e3723e20b214aa0/pinyin.txt",
|
||||
"sha256": "dcc769607c220b312fea3e71cb63421298b4b891b1f7356a95ab58f2c96fff81"
|
||||
},
|
||||
"character_pinyin": {
|
||||
"license": "MIT",
|
||||
"commit": "923b108dc5d45dee061324c011b478fb649f8b73",
|
||||
"url": "https://raw.githubusercontent.com/mozillazg/pinyin-data/923b108dc5d45dee061324c011b478fb649f8b73/pinyin.txt",
|
||||
"sha256": "621f8ca9eff8519f47e2b17b564fd318161e13bca07eea8c8e04993cd5d3b52e"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"baselineEntries": 65125,
|
||||
"jiebaWordsAccepted": 337338,
|
||||
"jiebaWordsUsingCharacterFallback": 293794,
|
||||
"outputEntries": 364926
|
||||
},
|
||||
"output": {
|
||||
"file": "osg_pinyin.dict.yaml",
|
||||
"sha256": "35b0664df8906712e4051392d83be35bf76587e4c9a7b068a71e0d6f7b989425"
|
||||
},
|
||||
"excluded": [
|
||||
"rime-ice (GPL-3.0)",
|
||||
"rime-double-pinyin (GPL-3.0)",
|
||||
"rime-essay (LGPL-3.0)",
|
||||
"rime-luna-pinyin (LGPL-3.0)"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
# Third-Party Notices
|
||||
|
||||
This repository packages upstream `librime` and its build dependencies into
|
||||
macOS and iOS XCFramework release artifacts.
|
||||
|
||||
The wrapper scripts, manifests, and documentation in this repository are
|
||||
licensed under the BSD 3-Clause License. See `LICENSE`.
|
||||
|
||||
Binary release artifacts include upstream `librime` and may include statically
|
||||
linked third-party dependency code resolved by vcpkg. Keep these notices with
|
||||
any redistributed binary artifacts.
|
||||
|
||||
Release assets include:
|
||||
|
||||
- `LICENSE.txt`: the license for this packaging wrapper.
|
||||
- `THIRD_PARTY_NOTICES.md`: this overview and the upstream `librime` notice.
|
||||
- `third-party-notices.zip`: vcpkg-provided license texts for bundled
|
||||
third-party dependencies.
|
||||
|
||||
## Upstream librime
|
||||
|
||||
Upstream project: <https://github.com/rime/librime>
|
||||
|
||||
License: BSD 3-Clause License
|
||||
|
||||
```text
|
||||
Copyright (c) 2014, RIME Developers
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived
|
||||
from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
```
|
||||
Binary file not shown.
@@ -68,7 +68,18 @@ public final class KeyboardState: ObservableObject {
|
||||
public var labelKey: String { "mode.polish" }
|
||||
}
|
||||
|
||||
/// Keyboard chrome surface. Voice is the default product mode; typing is
|
||||
/// a secondary QWERTY / pinyin surface for quick corrections.
|
||||
public enum Surface: String, CaseIterable, Identifiable, Sendable {
|
||||
case voice
|
||||
case typing
|
||||
|
||||
public var id: String { rawValue }
|
||||
}
|
||||
|
||||
@Published public var phase: Phase = .idle
|
||||
/// Active chrome. Forced to `.voice` while recording / processing.
|
||||
@Published public var surface: Surface = .voice
|
||||
@Published public var level: Double = 0
|
||||
@Published public var mode: InputMode = .polish
|
||||
@Published public var localeId: String = "auto"
|
||||
@@ -232,6 +243,20 @@ public final class KeyboardState: ObservableObject {
|
||||
/// Cursor-drag pad press lifecycle — updates `cursorDragActive` and
|
||||
/// lets the view controller reset vertical-navigation stickiness.
|
||||
public var setCursorDragActive: (Bool) -> Void = { _ in }
|
||||
/// Switch voice ↔ typing. No-ops when voice pipeline is active.
|
||||
public var setSurface: (Surface) -> Void = { _ in }
|
||||
|
||||
/// Recording / processing must stay on the voice surface.
|
||||
public var locksTypingSurface: Bool {
|
||||
switch phase {
|
||||
case .requestingPermissions, .recording, .processing:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var canEnterTypingSurface: Bool { !locksTypingSurface }
|
||||
|
||||
// MARK: - Preview helpers (DEBUG only)
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// LibrimeEngine.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Production Chinese IME backed by librime. Runtime access stays on the
|
||||
// main actor because UIInputViewController and its text proxy are main-only;
|
||||
// expensive schema deployment is performed by the host app beforehand.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public final class LibrimeEngine: RimeEngineBridging {
|
||||
public private(set) var composition: TypingComposition = .empty
|
||||
public private(set) var isReady = false
|
||||
public private(set) var schema: TypingInputSchema
|
||||
|
||||
private var language: TypingInputLanguage = .chinese
|
||||
private var bridge: OSGRimeBridge?
|
||||
private let configurationProvider: () -> TypingInputConfigurationSnapshot
|
||||
private let candidateLimit: Int
|
||||
|
||||
public init(
|
||||
schema: TypingInputSchema = .fullPinyin,
|
||||
candidateLimit: Int = 50,
|
||||
configurationProvider: @escaping () -> TypingInputConfigurationSnapshot = {
|
||||
TypingInputConfiguration.shared.snapshot
|
||||
}
|
||||
) {
|
||||
self.schema = schema
|
||||
self.candidateLimit = candidateLimit
|
||||
self.configurationProvider = configurationProvider
|
||||
}
|
||||
|
||||
public func prepare() async throws {
|
||||
if isReady { return }
|
||||
guard RimeResourceInstaller.isReady else {
|
||||
throw RimeResourceError.resourcesNotInstalled
|
||||
}
|
||||
|
||||
let paths = try RimeResourcePaths.resolve()
|
||||
let runtime = OSGRimeBridge(
|
||||
sharedDataDirectory: paths.sharedData.path,
|
||||
userDataDirectory: paths.userData.path,
|
||||
distributionVersion: RimeResourceInstaller.resourceVersion
|
||||
)
|
||||
try runtime.start()
|
||||
|
||||
let configured = configurationProvider()
|
||||
schema = configured.schema
|
||||
guard runtime.selectSchema(schema.rawValue) else {
|
||||
runtime.stopSession()
|
||||
throw LibrimeEngineError.schemaUnavailable(schema.rawValue)
|
||||
}
|
||||
_ = runtime.setASCIIMode(language == .english)
|
||||
bridge = runtime
|
||||
isReady = true
|
||||
_ = refresh()
|
||||
}
|
||||
|
||||
public func teardown() {
|
||||
bridge?.clearComposition()
|
||||
bridge?.stopSession()
|
||||
bridge = nil
|
||||
composition = .empty
|
||||
isReady = false
|
||||
}
|
||||
|
||||
public func setLanguage(_ language: TypingInputLanguage) {
|
||||
self.language = language
|
||||
_ = bridge?.setASCIIMode(language == .english)
|
||||
if language == .english {
|
||||
bridge?.clearComposition()
|
||||
composition = .empty
|
||||
} else {
|
||||
_ = refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setSchema(_ schema: TypingInputSchema) -> Bool {
|
||||
guard let bridge else {
|
||||
self.schema = schema
|
||||
return false
|
||||
}
|
||||
bridge.clearComposition()
|
||||
guard bridge.selectSchema(schema.rawValue) else { return false }
|
||||
self.schema = schema
|
||||
composition = .empty
|
||||
return true
|
||||
}
|
||||
|
||||
public func processCharacter(_ character: Character) -> String? {
|
||||
guard language == .chinese,
|
||||
let scalar = character.asciiValue,
|
||||
bridge?.processKeyCode(Int32(scalar), modifiers: 0) == true else {
|
||||
return nil
|
||||
}
|
||||
return refresh()
|
||||
}
|
||||
|
||||
public func processBackspace() -> String? {
|
||||
guard bridge?.processKeyCode(OSGRimeKeyBackSpace, modifiers: 0) == true else {
|
||||
return nil
|
||||
}
|
||||
return refresh()
|
||||
}
|
||||
|
||||
public func processSpace() -> String? {
|
||||
guard bridge?.processKeyCode(32, modifiers: 0) == true else {
|
||||
return " "
|
||||
}
|
||||
return refresh()
|
||||
}
|
||||
|
||||
public func processReturn() -> String? {
|
||||
guard bridge?.processKeyCode(OSGRimeKeyReturn, modifiers: 0) == true else {
|
||||
return "\n"
|
||||
}
|
||||
return refresh()
|
||||
}
|
||||
|
||||
public func selectCandidate(at index: Int) -> String {
|
||||
guard bridge?.selectCandidate(at: index) == true else { return "" }
|
||||
return refresh() ?? ""
|
||||
}
|
||||
|
||||
public func flushPreedit() -> String {
|
||||
let raw = bridge?.rawInput() ?? ""
|
||||
bridge?.clearComposition()
|
||||
composition = .empty
|
||||
return raw
|
||||
}
|
||||
|
||||
public func clearComposition() {
|
||||
bridge?.clearComposition()
|
||||
composition = .empty
|
||||
}
|
||||
|
||||
/// Copies librime-owned memory into Sendable Swift value types and returns
|
||||
/// any commit emitted by the preceding key operation.
|
||||
@discardableResult
|
||||
private func refresh() -> String? {
|
||||
guard let snapshot = bridge?.snapshot(withCandidateLimit: candidateLimit) else {
|
||||
composition = .empty
|
||||
return nil
|
||||
}
|
||||
let preedit = snapshot.preedit
|
||||
composition = TypingComposition(
|
||||
preedit: preedit,
|
||||
candidates: snapshot.candidates.enumerated().map { index, candidate in
|
||||
TypingCandidate(
|
||||
id: "\(preedit)|\(index)|\(candidate.text)",
|
||||
text: candidate.text,
|
||||
annotation: candidate.comment.isEmpty ? nil : candidate.comment
|
||||
)
|
||||
}
|
||||
)
|
||||
return snapshot.commitText.isEmpty ? nil : snapshot.commitText
|
||||
}
|
||||
}
|
||||
|
||||
public enum LibrimeEngineError: LocalizedError {
|
||||
case schemaUnavailable(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .schemaUnavailable(let id):
|
||||
return "输入方案不可用:\(id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Character {
|
||||
var asciiValue: UInt8? {
|
||||
guard let scalar = unicodeScalars.first,
|
||||
unicodeScalars.count == 1,
|
||||
scalar.value <= UInt8.max else {
|
||||
return nil
|
||||
}
|
||||
return UInt8(scalar.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// RimeEngineBridging.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Engine façade for the typing surface. `LibrimeEngine` is the production
|
||||
// implementation; the protocol keeps SwiftUI independent of the C runtime.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Input language for the typing surface (not ASR locale).
|
||||
public enum TypingInputLanguage: String, CaseIterable, Identifiable, Sendable {
|
||||
case chinese
|
||||
case english
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var shortLabel: String {
|
||||
switch self {
|
||||
case .chinese: return "中"
|
||||
case .english: return "英"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One candidate row item after composing.
|
||||
public struct TypingCandidate: Identifiable, Equatable, Sendable {
|
||||
public let id: String
|
||||
public let text: String
|
||||
public let annotation: String?
|
||||
|
||||
public init(id: String = UUID().uuidString, text: String, annotation: String? = nil) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.annotation = annotation
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the UI observes while composing.
|
||||
public struct TypingComposition: Equatable, Sendable {
|
||||
public var preedit: String
|
||||
public var candidates: [TypingCandidate]
|
||||
|
||||
public init(preedit: String = "", candidates: [TypingCandidate] = []) {
|
||||
self.preedit = preedit
|
||||
self.candidates = candidates
|
||||
}
|
||||
|
||||
public static let empty = TypingComposition()
|
||||
}
|
||||
|
||||
/// Bridge between key events and IME state. Keep this small so Phase 2
|
||||
/// can swap KeyboardKit-style shells or librime without UI rewrites.
|
||||
@MainActor
|
||||
public protocol RimeEngineBridging: AnyObject {
|
||||
var composition: TypingComposition { get }
|
||||
var isReady: Bool { get }
|
||||
var schema: TypingInputSchema { get }
|
||||
|
||||
/// Load dictionaries / open session. Safe to call repeatedly.
|
||||
func prepare() async throws
|
||||
/// Drop heavy caches (leave typing mode / memory warning).
|
||||
func teardown()
|
||||
|
||||
func setLanguage(_ language: TypingInputLanguage)
|
||||
@discardableResult
|
||||
func setSchema(_ schema: TypingInputSchema) -> Bool
|
||||
/// Process one key and return newly committed text, if any.
|
||||
func processCharacter(_ character: Character) -> String?
|
||||
func processBackspace() -> String?
|
||||
func processSpace() -> String?
|
||||
func processReturn() -> String?
|
||||
/// Commit candidate at index; returns text to insert (empty if invalid).
|
||||
func selectCandidate(at index: Int) -> String
|
||||
/// Force-commit current preedit as raw latin (or empty).
|
||||
func flushPreedit() -> String
|
||||
func clearComposition()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// RimeResourceInstaller.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// The host app owns Rime deployment. The keyboard extension only opens
|
||||
// an already-built session, keeping expensive maintenance work out of
|
||||
// the extension's constrained lifecycle.
|
||||
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
public enum RimeResourceError: LocalizedError {
|
||||
case appGroupUnavailable
|
||||
case bundledResourceMissing(String)
|
||||
case lockUnavailable
|
||||
case deploymentFailed
|
||||
case resourcesNotInstalled
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .appGroupUnavailable:
|
||||
return "App Group 不可用"
|
||||
case .bundledResourceMissing(let name):
|
||||
return "缺少输入法资源:\(name)"
|
||||
case .lockUnavailable:
|
||||
return "输入法资源正在被其他进程更新"
|
||||
case .deploymentFailed:
|
||||
return "输入法资源部署失败"
|
||||
case .resourcesNotInstalled:
|
||||
return "请先打开 OSGKeyboard 完成输入法初始化"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RimeResourcePaths: Sendable {
|
||||
public let root: URL
|
||||
public let sharedData: URL
|
||||
public let userData: URL
|
||||
public let lockFile: URL
|
||||
|
||||
public static func resolve() throws -> RimeResourcePaths {
|
||||
guard let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: AppGroup.identifier
|
||||
) else {
|
||||
throw RimeResourceError.appGroupUnavailable
|
||||
}
|
||||
let root = container.appendingPathComponent("Rime", isDirectory: true)
|
||||
return RimeResourcePaths(
|
||||
root: root,
|
||||
sharedData: root.appendingPathComponent("SharedSupport", isDirectory: true),
|
||||
userData: root.appendingPathComponent("UserData", isDirectory: true),
|
||||
lockFile: root.appendingPathComponent(".deployment.lock")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public actor RimeResourceInstaller {
|
||||
public static let shared = RimeResourceInstaller()
|
||||
public static let resourceVersion = "2.0.0"
|
||||
|
||||
public init() {}
|
||||
|
||||
public static var isReady: Bool {
|
||||
guard TypingInputConfiguration.installedResourceVersion() == resourceVersion,
|
||||
let paths = try? RimeResourcePaths.resolve() else {
|
||||
return false
|
||||
}
|
||||
return FileManager.default.fileExists(
|
||||
atPath: paths.userData.appendingPathComponent("build").path
|
||||
)
|
||||
}
|
||||
|
||||
/// Installs source data and asks librime to prebuild schemas. Call only
|
||||
/// from the host app, never from the keyboard extension.
|
||||
public func installIfNeeded(
|
||||
configuration: TypingInputConfigurationSnapshot,
|
||||
force: Bool = false
|
||||
) throws {
|
||||
if !force, Self.isReady { return }
|
||||
|
||||
let paths = try RimeResourcePaths.resolve()
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(at: paths.root, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: paths.userData, withIntermediateDirectories: true)
|
||||
|
||||
let descriptor = open(paths.lockFile.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)
|
||||
guard descriptor >= 0 else { throw RimeResourceError.lockUnavailable }
|
||||
defer {
|
||||
flock(descriptor, LOCK_UN)
|
||||
close(descriptor)
|
||||
}
|
||||
guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else {
|
||||
throw RimeResourceError.lockUnavailable
|
||||
}
|
||||
|
||||
let staging = paths.root.appendingPathComponent(
|
||||
"SharedSupport.staging-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? fileManager.removeItem(at: staging) }
|
||||
try fileManager.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
|
||||
for resource in ["osg_pinyin.dict", "manifest"] {
|
||||
let ext = resource == "manifest" ? "json" : "yaml"
|
||||
guard let source = Bundle(for: RimeResourceBundleToken.self).url(
|
||||
forResource: resource,
|
||||
withExtension: ext
|
||||
) else {
|
||||
throw RimeResourceError.bundledResourceMissing("\(resource).\(ext)")
|
||||
}
|
||||
try fileManager.copyItem(
|
||||
at: source,
|
||||
to: staging.appendingPathComponent("\(resource).\(ext)")
|
||||
)
|
||||
}
|
||||
|
||||
try RimeSchemaGenerator.defaultConfiguration().write(
|
||||
to: staging.appendingPathComponent("default.yaml"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
for schema in TypingInputSchema.allCases {
|
||||
try RimeSchemaGenerator.schema(
|
||||
for: schema,
|
||||
fuzzyPairs: configuration.fuzzyPairs
|
||||
).write(
|
||||
to: staging.appendingPathComponent("\(schema.rawValue).schema.yaml"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
}
|
||||
|
||||
if fileManager.fileExists(atPath: paths.sharedData.path) {
|
||||
try fileManager.removeItem(at: paths.sharedData)
|
||||
}
|
||||
try fileManager.moveItem(at: staging, to: paths.sharedData)
|
||||
|
||||
let bridge = OSGRimeBridge(
|
||||
sharedDataDirectory: paths.sharedData.path,
|
||||
userDataDirectory: paths.userData.path,
|
||||
distributionVersion: Self.resourceVersion
|
||||
)
|
||||
do {
|
||||
try bridge.deploy(withFullCheck: true)
|
||||
} catch {
|
||||
bridge.finalizeRuntime()
|
||||
throw error
|
||||
}
|
||||
bridge.finalizeRuntime()
|
||||
|
||||
TypingInputConfiguration.setInstalledResourceVersion(Self.resourceVersion)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func syncUserData() throws {
|
||||
let paths = try RimeResourcePaths.resolve()
|
||||
let bridge = OSGRimeBridge(
|
||||
sharedDataDirectory: paths.sharedData.path,
|
||||
userDataDirectory: paths.userData.path,
|
||||
distributionVersion: Self.resourceVersion
|
||||
)
|
||||
try bridge.start()
|
||||
// Destroying the session and finalizing librime flushes LevelDB
|
||||
// user dictionaries. `sync_user_data` is for external Rime sync
|
||||
// deployments and is intentionally not needed here.
|
||||
bridge.finalizeRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
private final class RimeResourceBundleToken: NSObject {}
|
||||
@@ -0,0 +1,187 @@
|
||||
// RimeSchemaGenerator.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Generates OSG-owned schemas from public Microsoft/Sogou key maps.
|
||||
// No GPL Rime schema files are copied or distributed.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum RimeSchemaGenerator {
|
||||
public static func defaultConfiguration() -> String {
|
||||
"""
|
||||
# Generated by OSGKeyboard.
|
||||
config_version: "1.0"
|
||||
schema_list:
|
||||
- schema: \(TypingInputSchema.fullPinyin.rawValue)
|
||||
- schema: \(TypingInputSchema.microsoftDoublePinyin.rawValue)
|
||||
- schema: \(TypingInputSchema.sogouDoublePinyin.rawValue)
|
||||
switcher:
|
||||
caption: 输入方案
|
||||
hotkeys: []
|
||||
menu:
|
||||
page_size: 9
|
||||
ascii_composer:
|
||||
good_old_caps_lock: true
|
||||
switch_key:
|
||||
Shift_L: noop
|
||||
Shift_R: noop
|
||||
Control_L: noop
|
||||
Control_R: noop
|
||||
key_binder:
|
||||
bindings: []
|
||||
recognizer:
|
||||
patterns: {}
|
||||
"""
|
||||
}
|
||||
|
||||
public static func schema(
|
||||
for inputSchema: TypingInputSchema,
|
||||
fuzzyPairs: Set<PinyinFuzzyPair>
|
||||
) -> String {
|
||||
let alphabet = inputSchema == .fullPinyin
|
||||
? "zyxwvutsrqponmlkjihgfedcba"
|
||||
: "zyxwvutsrqponmlkjihgfedcba;"
|
||||
let algebra = fuzzyRules(fuzzyPairs) + algebraRules(for: inputSchema)
|
||||
let algebraYAML = algebra.map { " - '\($0)'" }.joined(separator: "\n")
|
||||
|
||||
return """
|
||||
# Generated by OSGKeyboard. Do not edit; change settings in the host app.
|
||||
schema:
|
||||
schema_id: \(inputSchema.rawValue)
|
||||
name: \(inputSchema.displayName)
|
||||
version: "1.0"
|
||||
author:
|
||||
- OSGKeyboard contributors
|
||||
description: |
|
||||
Commercially permissive OSG schema backed by osg_pinyin.
|
||||
|
||||
switches:
|
||||
- name: ascii_mode
|
||||
reset: 0
|
||||
states: [中, 英]
|
||||
|
||||
engine:
|
||||
processors:
|
||||
- ascii_composer
|
||||
- recognizer
|
||||
- key_binder
|
||||
- speller
|
||||
- punctuator
|
||||
- selector
|
||||
- navigator
|
||||
- express_editor
|
||||
segmentors:
|
||||
- ascii_segmentor
|
||||
- matcher
|
||||
- abc_segmentor
|
||||
- punct_segmentor
|
||||
- fallback_segmentor
|
||||
translators:
|
||||
- punct_translator
|
||||
- script_translator
|
||||
|
||||
speller:
|
||||
alphabet: "\(alphabet)"
|
||||
initials: "\(alphabet.replacingOccurrences(of: ";", with: ""))"
|
||||
delimiter: " '"
|
||||
algebra:
|
||||
\(algebraYAML)
|
||||
|
||||
translator:
|
||||
dictionary: osg_pinyin
|
||||
prism: \(inputSchema.rawValue)
|
||||
enable_sentence: true
|
||||
enable_completion: true
|
||||
enable_user_dict: true
|
||||
initial_quality: 1.2
|
||||
|
||||
punctuator:
|
||||
half_shape:
|
||||
",": ","
|
||||
".": "。"
|
||||
"?": "?"
|
||||
"!": "!"
|
||||
|
||||
key_binder:
|
||||
import_preset: default
|
||||
|
||||
recognizer:
|
||||
import_preset: default
|
||||
"""
|
||||
}
|
||||
|
||||
/// Rules run against full-pinyin dictionary codes before double-pinyin
|
||||
/// transforms, so fuzzy pairs work consistently in all three schemas.
|
||||
public static func fuzzyRules(_ enabled: Set<PinyinFuzzyPair>) -> [String] {
|
||||
var rules: [String] = []
|
||||
for pair in PinyinFuzzyPair.allCases where enabled.contains(pair) {
|
||||
switch pair {
|
||||
case .zhZ:
|
||||
rules += ["derive/^zh/z/", "derive/^z([^h])/zh$1/"]
|
||||
case .chC:
|
||||
rules += ["derive/^ch/c/", "derive/^c([^h])/ch$1/"]
|
||||
case .shS:
|
||||
rules += ["derive/^sh/s/", "derive/^s([^h])/sh$1/"]
|
||||
case .nL:
|
||||
rules += ["derive/^n/l/", "derive/^l/n/"]
|
||||
case .fH:
|
||||
rules += ["derive/^f/h/", "derive/^h/f/"]
|
||||
case .anAng:
|
||||
rules += ["derive/ang$/an/", "derive/an$/ang/"]
|
||||
case .enEng:
|
||||
rules += ["derive/eng$/en/", "derive/en$/eng/"]
|
||||
case .inIng:
|
||||
rules += ["derive/ing$/in/", "derive/in$/ing/"]
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
private static func algebraRules(for schema: TypingInputSchema) -> [String] {
|
||||
switch schema {
|
||||
case .fullPinyin:
|
||||
return [
|
||||
"derive/^([jqxy])u$/$1v/",
|
||||
"abbrev/^([a-z]).+$/$1/"
|
||||
]
|
||||
|
||||
case .microsoftDoublePinyin, .sogouDoublePinyin:
|
||||
// Microsoft and Sogou's commonly shipped layouts are key-compatible.
|
||||
// Uppercase markers prevent transforms from matching each other.
|
||||
return [
|
||||
"erase/^xx$/",
|
||||
"derive/^([jqxy])u$/$1v/",
|
||||
"derive/^([aoe].*)$/o$1/",
|
||||
"xform/^([ae])(.*)$/$1$1$2/",
|
||||
"xform/iu$/Q/",
|
||||
"xform/[iu]a$/W/",
|
||||
"xform/er$|[uv]an$/R/",
|
||||
"xform/[uv]e$/T/",
|
||||
"xform/v$|uai$/Y/",
|
||||
"xform/^sh/U/",
|
||||
"xform/^ch/I/",
|
||||
"xform/^zh/V/",
|
||||
"xform/uo$/O/",
|
||||
"xform/[uv]n$/P/",
|
||||
"xform/(.)i?ong$/$1S/",
|
||||
"xform/[iu]ang$/D/",
|
||||
"xform/(.)en$/$1F/",
|
||||
"xform/(.)eng$/$1G/",
|
||||
"xform/(.)ang$/$1H/",
|
||||
"xform/ian$/M/",
|
||||
"xform/(.)an$/$1J/",
|
||||
"xform/iao$/C/",
|
||||
"xform/(.)ao$/$1K/",
|
||||
"xform/(.)ai$/$1L/",
|
||||
"xform/(.)ei$/$1Z/",
|
||||
"xform/ie$/X/",
|
||||
"xform/ui$/V/",
|
||||
"derive/T$/V/",
|
||||
"xform/(.)ou$/$1B/",
|
||||
"xform/in$/N/",
|
||||
"xform/ing$/;/",
|
||||
"xlit/QWRTYUIOPSDFGHMJCKLZXVBN/qwrtyuiopsdfghmjcklzxvbn/"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// TypingLayoutProviding.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Phase 2 escape hatch: replace the in-repo SwiftUI key shell with a
|
||||
// KeyboardKit-based (or other) layout without changing the engine bridge.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Page shown by the typing key shell.
|
||||
public enum TypingKeyPage: String, CaseIterable, Sendable {
|
||||
case letters
|
||||
case numbers
|
||||
case symbols
|
||||
}
|
||||
|
||||
/// Abstraction over “which characters the current page shows”.
|
||||
/// The Phase 1 SwiftUI keyboard reads this; a future Kit-backed shell
|
||||
/// can feed the same consumer.
|
||||
public protocol TypingLayoutProviding: Sendable {
|
||||
func rows(for page: TypingKeyPage, shiftActive: Bool) -> [[String]]
|
||||
}
|
||||
|
||||
/// Standard phone QWERTY + 123 + light symbols (NanoMouse / system-like).
|
||||
public struct StandardTypingLayout: TypingLayoutProviding {
|
||||
public init() {}
|
||||
|
||||
public func rows(for page: TypingKeyPage, shiftActive: Bool) -> [[String]] {
|
||||
switch page {
|
||||
case .letters:
|
||||
let upper = shiftActive
|
||||
let map: (String) -> String = { upper ? $0.uppercased() : $0 }
|
||||
return [
|
||||
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"].map(map),
|
||||
["a", "s", "d", "f", "g", "h", "j", "k", "l"].map(map),
|
||||
["⇧", "z", "x", "c", "v", "b", "n", "m", "⌫"].map { $0.count == 1 ? map($0) : $0 }
|
||||
]
|
||||
case .numbers:
|
||||
return [
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
||||
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
|
||||
["#+=", ".", ",", "?", "!", "'", "⌫"]
|
||||
]
|
||||
case .symbols:
|
||||
return [
|
||||
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
|
||||
["_", "\\", "|", "~", "<", ">", "€", "£", "¥", "·"],
|
||||
["123", ".", ",", "?", "!", "'", "⌫"],
|
||||
[",", "。", "、", "?", "!", ":", ";"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// TypingSessionController.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Owns the typing-surface engine + layout provider. Injected into the
|
||||
// keyboard extension; torn down when leaving typing mode.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
public final class TypingSessionController: ObservableObject {
|
||||
@Published public private(set) var language: TypingInputLanguage = .chinese
|
||||
@Published public private(set) var page: TypingKeyPage = .letters
|
||||
@Published public private(set) var shiftActive: Bool = false
|
||||
@Published public private(set) var capsLock: Bool = false
|
||||
@Published public private(set) var composition: TypingComposition = .empty
|
||||
@Published public private(set) var engineReady: Bool = false
|
||||
@Published public private(set) var schema: TypingInputSchema
|
||||
@Published public var lastError: String?
|
||||
|
||||
public let layout: TypingLayoutProviding
|
||||
private let engine: RimeEngineBridging
|
||||
private var prepared = false
|
||||
|
||||
public init(
|
||||
engine: RimeEngineBridging = LibrimeEngine(),
|
||||
layout: TypingLayoutProviding = StandardTypingLayout()
|
||||
) {
|
||||
self.engine = engine
|
||||
self.layout = layout
|
||||
schema = engine.schema
|
||||
}
|
||||
|
||||
public var keyRows: [[String]] {
|
||||
var rows = layout.rows(for: page, shiftActive: shiftActive || capsLock)
|
||||
if page == .letters,
|
||||
language == .chinese,
|
||||
schema != .fullPinyin,
|
||||
rows.indices.contains(2),
|
||||
rows[2].first == "⇧" {
|
||||
// Microsoft/Sogou use semicolon for "ing"; Chinese composition
|
||||
// does not need Shift, so keep the standard row width stable.
|
||||
rows[2][0] = ";"
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
public func enterTypingMode() {
|
||||
TypingInputConfiguration.shared.reload()
|
||||
Task { await prepareIfNeeded() }
|
||||
}
|
||||
|
||||
public func leaveTypingMode() {
|
||||
engine.teardown()
|
||||
prepared = false
|
||||
engineReady = false
|
||||
composition = .empty
|
||||
page = .letters
|
||||
shiftActive = false
|
||||
capsLock = false
|
||||
}
|
||||
|
||||
public func toggleLanguage() -> String {
|
||||
let next: TypingInputLanguage = language == .chinese ? .english : .chinese
|
||||
return setLanguage(next)
|
||||
}
|
||||
|
||||
/// Selects a specific language for the shared voice / Chinese / English
|
||||
/// capsule. Any active preedit is returned so callers can commit it
|
||||
/// before switching modes.
|
||||
public func setLanguage(_ newLanguage: TypingInputLanguage) -> String {
|
||||
guard language != newLanguage else { return "" }
|
||||
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
|
||||
language = newLanguage
|
||||
engine.setLanguage(newLanguage)
|
||||
composition = engine.composition
|
||||
page = .letters
|
||||
return raw
|
||||
}
|
||||
|
||||
/// Flushes raw preedit, selects the next built-in scheme, and returns the
|
||||
/// raw text that the caller should insert before switching.
|
||||
public func cycleSchema() -> String {
|
||||
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
|
||||
let schemas = TypingInputSchema.allCases
|
||||
let current = schemas.firstIndex(of: schema) ?? 0
|
||||
let next = schemas[(current + 1) % schemas.count]
|
||||
if engine.setSchema(next) {
|
||||
schema = next
|
||||
TypingInputConfiguration.shared.schema = next
|
||||
}
|
||||
composition = engine.composition
|
||||
return raw
|
||||
}
|
||||
|
||||
public func setPage(_ page: TypingKeyPage) {
|
||||
self.page = page
|
||||
shiftActive = false
|
||||
}
|
||||
|
||||
/// Handle a visible key label. Returns text the proxy should insert now
|
||||
/// (may be empty when composing Chinese).
|
||||
public func handleKey(_ label: String) -> String {
|
||||
switch label {
|
||||
case "⇧":
|
||||
if shiftActive {
|
||||
capsLock = true
|
||||
shiftActive = false
|
||||
} else if capsLock {
|
||||
capsLock = false
|
||||
} else {
|
||||
shiftActive = true
|
||||
}
|
||||
return ""
|
||||
case "⌫":
|
||||
if language == .chinese, !engine.composition.preedit.isEmpty {
|
||||
let committed = engine.processBackspace() ?? ""
|
||||
composition = engine.composition
|
||||
return committed
|
||||
}
|
||||
return "\u{8}" // sentinel: caller deletes backward
|
||||
case "123":
|
||||
setPage(.numbers)
|
||||
return ""
|
||||
case "#+=":
|
||||
setPage(.symbols)
|
||||
return ""
|
||||
case "ABC", "abc":
|
||||
setPage(.letters)
|
||||
return ""
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if page != .letters {
|
||||
// Number/symbol: insert directly
|
||||
let out = label
|
||||
if !capsLock { shiftActive = false }
|
||||
return out
|
||||
}
|
||||
|
||||
guard let ch = label.first else { return "" }
|
||||
|
||||
if language == .english {
|
||||
let out = String(ch)
|
||||
if !capsLock { shiftActive = false }
|
||||
return out
|
||||
}
|
||||
|
||||
// Chinese letters → compose
|
||||
let committed = engine.processCharacter(ch) ?? ""
|
||||
composition = engine.composition
|
||||
if !capsLock { shiftActive = false }
|
||||
return committed
|
||||
}
|
||||
|
||||
public func handleSpace() -> String {
|
||||
if language == .english { return " " }
|
||||
let text = engine.processSpace() ?? " "
|
||||
composition = engine.composition
|
||||
return text
|
||||
}
|
||||
|
||||
public func handleReturn() -> String {
|
||||
if language == .english { return "\n" }
|
||||
let text = engine.processReturn() ?? "\n"
|
||||
composition = engine.composition
|
||||
return text
|
||||
}
|
||||
|
||||
public func selectCandidate(at index: Int) -> String {
|
||||
let text = engine.selectCandidate(at: index)
|
||||
composition = engine.composition
|
||||
return text
|
||||
}
|
||||
|
||||
private func prepareIfNeeded() async {
|
||||
guard !prepared else { return }
|
||||
do {
|
||||
try await engine.prepare()
|
||||
engine.setLanguage(language)
|
||||
prepared = true
|
||||
engineReady = engine.isReady
|
||||
schema = engine.schema
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
engineReady = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// OpenSourceLicenseCatalogTests.swift
|
||||
// OSGKeyboard · Unit Tests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboard
|
||||
|
||||
final class OpenSourceLicenseCatalogTests: XCTestCase {
|
||||
func testIOSCatalogIncludesRimeAndDictionaryDataOnly() {
|
||||
let entries = OpenSourceLicenseCatalog.entries(for: .iOS)
|
||||
let ids = Set(entries.map(\.id))
|
||||
|
||||
XCTAssertTrue(ids.contains("librime-static"))
|
||||
XCTAssertTrue(ids.contains("rime-pinyin-simp"))
|
||||
XCTAssertTrue(ids.contains("jieba"))
|
||||
XCTAssertTrue(ids.contains("phrase-pinyin-data"))
|
||||
XCTAssertTrue(ids.contains("pinyin-data"))
|
||||
XCTAssertFalse(ids.contains("mlx-audio-swift"))
|
||||
}
|
||||
|
||||
func testMacCatalogExcludesIOSRimeStack() {
|
||||
let entries = OpenSourceLicenseCatalog.entries(for: .macOS)
|
||||
let ids = Set(entries.map(\.id))
|
||||
|
||||
XCTAssertEqual(ids, ["mlx-audio-swift"])
|
||||
}
|
||||
|
||||
func testBundledDictionaryLicensesKeepCopyrightNotices() throws {
|
||||
let entries = OpenSourceLicenseCatalog.entries(for: .iOS)
|
||||
let jieba = try XCTUnwrap(entries.first { $0.id == "jieba" })
|
||||
let phrase = try XCTUnwrap(entries.first { $0.id == "phrase-pinyin-data" })
|
||||
let pinyin = try XCTUnwrap(entries.first { $0.id == "pinyin-data" })
|
||||
|
||||
XCTAssertTrue(jieba.licenseText.contains("Copyright (c) 2013 Sun Junyi"))
|
||||
XCTAssertTrue(phrase.licenseText.contains("Copyright (c) 2017 mozillazg"))
|
||||
XCTAssertTrue(pinyin.licenseText.contains("Copyright (c) 2016 mozillazg"))
|
||||
}
|
||||
|
||||
func testLibrimeEntryContainsTransitiveDependencyNotices() throws {
|
||||
let entry = try XCTUnwrap(
|
||||
OpenSourceLicenseCatalog.entries(for: .iOS)
|
||||
.first { $0.id == "librime-static" }
|
||||
)
|
||||
|
||||
XCTAssertTrue(entry.licenseText.contains("opencc.txt"))
|
||||
XCTAssertTrue(entry.licenseText.contains("leveldb.txt"))
|
||||
XCTAssertTrue(entry.licenseText.contains("yaml-cpp.txt"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
#import "include/OSGRimeBridge.h"
|
||||
|
||||
@import RimeStatic;
|
||||
|
||||
const int OSGRimeKeyBackSpace = 0xff08;
|
||||
const int OSGRimeKeyReturn = 0xff0d;
|
||||
|
||||
static NSString *const OSGRimeErrorDomain = @"com.osgkeyboard.rime";
|
||||
static RimeApi_stdbool *OSGRimeAPI(void) {
|
||||
return rime_get_api_stdbool();
|
||||
}
|
||||
|
||||
@implementation OSGRimeCandidate
|
||||
|
||||
- (instancetype)initWithText:(NSString *)text comment:(NSString *)comment {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_text = [text copy];
|
||||
_comment = [comment copy];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@implementation OSGRimeSnapshot
|
||||
|
||||
- (instancetype)initWithPreedit:(NSString *)preedit
|
||||
candidates:(NSArray<OSGRimeCandidate *> *)candidates
|
||||
commitText:(NSString *)commitText
|
||||
composing:(BOOL)composing {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_preedit = [preedit copy];
|
||||
_candidates = [candidates copy];
|
||||
_commitText = [commitText copy];
|
||||
_composing = composing;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@interface OSGRimeBridge ()
|
||||
@property(nonatomic, copy) NSString *sharedDataDirectory;
|
||||
@property(nonatomic, copy) NSString *userDataDirectory;
|
||||
@property(nonatomic, copy) NSString *distributionVersion;
|
||||
@property(nonatomic) RimeSessionId session;
|
||||
@end
|
||||
|
||||
@implementation OSGRimeBridge
|
||||
|
||||
static BOOL sSetupComplete = NO;
|
||||
static BOOL sInitialized = NO;
|
||||
|
||||
- (instancetype)initWithSharedDataDirectory:(NSString *)sharedDataDirectory
|
||||
userDataDirectory:(NSString *)userDataDirectory
|
||||
distributionVersion:(NSString *)distributionVersion {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_sharedDataDirectory = [sharedDataDirectory copy];
|
||||
_userDataDirectory = [userDataDirectory copy];
|
||||
_distributionVersion = [distributionVersion copy];
|
||||
_session = 0;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (BOOL)running {
|
||||
return self.session != 0 && OSGRimeAPI()->find_session(self.session);
|
||||
}
|
||||
|
||||
- (void)populateTraits:(RimeTraits *)traits {
|
||||
traits->shared_data_dir = self.sharedDataDirectory.UTF8String;
|
||||
traits->user_data_dir = self.userDataDirectory.UTF8String;
|
||||
traits->distribution_name = "OSGKeyboard";
|
||||
traits->distribution_code_name = "OSGKeyboard";
|
||||
traits->distribution_version = self.distributionVersion.UTF8String;
|
||||
traits->app_name = "rime.OSGKeyboard";
|
||||
traits->min_log_level = 2;
|
||||
}
|
||||
|
||||
- (BOOL)prepareRuntime:(NSError **)error {
|
||||
RimeApi_stdbool *api = OSGRimeAPI();
|
||||
if (api == NULL) {
|
||||
if (error) {
|
||||
*error = [NSError errorWithDomain:OSGRimeErrorDomain
|
||||
code:1
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"librime API unavailable"}];
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
@synchronized([OSGRimeBridge class]) {
|
||||
RIME_STRUCT(RimeTraits, traits);
|
||||
[self populateTraits:&traits];
|
||||
if (!sSetupComplete) {
|
||||
api->setup(&traits);
|
||||
sSetupComplete = YES;
|
||||
}
|
||||
if (!sInitialized) {
|
||||
api->initialize(&traits);
|
||||
sInitialized = YES;
|
||||
}
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)start:(NSError **)error {
|
||||
if (![self prepareRuntime:error]) {
|
||||
return NO;
|
||||
}
|
||||
if ([self running]) {
|
||||
return YES;
|
||||
}
|
||||
self.session = OSGRimeAPI()->create_session();
|
||||
if (self.session == 0) {
|
||||
if (error) {
|
||||
*error = [NSError errorWithDomain:OSGRimeErrorDomain
|
||||
code:2
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"Unable to create Rime session"}];
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)stopSession {
|
||||
if (self.session != 0) {
|
||||
OSGRimeAPI()->destroy_session(self.session);
|
||||
self.session = 0;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)finalizeRuntime {
|
||||
[self stopSession];
|
||||
@synchronized([OSGRimeBridge class]) {
|
||||
if (sInitialized) {
|
||||
OSGRimeAPI()->finalize();
|
||||
sInitialized = NO;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)deployWithFullCheck:(BOOL)fullCheck error:(NSError **)error {
|
||||
if (![self prepareRuntime:error]) {
|
||||
return NO;
|
||||
}
|
||||
RimeApi_stdbool *api = OSGRimeAPI();
|
||||
Bool started = api->start_maintenance(fullCheck ? True : False);
|
||||
if (started && api->is_maintenance_mode()) {
|
||||
api->join_maintenance_thread();
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)selectSchema:(NSString *)schemaIdentifier {
|
||||
if (![self running]) {
|
||||
return NO;
|
||||
}
|
||||
return OSGRimeAPI()->select_schema(self.session, schemaIdentifier.UTF8String);
|
||||
}
|
||||
|
||||
- (BOOL)setASCIIMode:(BOOL)enabled {
|
||||
if (![self running]) {
|
||||
return NO;
|
||||
}
|
||||
OSGRimeAPI()->set_option(self.session, "ascii_mode", enabled ? True : False);
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)processKeyCode:(int)keyCode modifiers:(int)modifiers {
|
||||
if (![self running]) {
|
||||
return NO;
|
||||
}
|
||||
return OSGRimeAPI()->process_key(self.session, keyCode, modifiers);
|
||||
}
|
||||
|
||||
- (BOOL)selectCandidateAtIndex:(NSInteger)index {
|
||||
if (![self running] || index < 0) {
|
||||
return NO;
|
||||
}
|
||||
return OSGRimeAPI()->select_candidate(self.session, (size_t)index);
|
||||
}
|
||||
|
||||
- (BOOL)commitComposition {
|
||||
if (![self running]) {
|
||||
return NO;
|
||||
}
|
||||
return OSGRimeAPI()->commit_composition(self.session);
|
||||
}
|
||||
|
||||
- (void)clearComposition {
|
||||
if ([self running]) {
|
||||
OSGRimeAPI()->clear_composition(self.session);
|
||||
}
|
||||
}
|
||||
|
||||
- (NSString *)rawInput {
|
||||
if (![self running]) {
|
||||
return @"";
|
||||
}
|
||||
const char *input = OSGRimeAPI()->get_input(self.session);
|
||||
return input ? [NSString stringWithUTF8String:input] : @"";
|
||||
}
|
||||
|
||||
- (OSGRimeSnapshot *)snapshotWithCandidateLimit:(NSInteger)limit {
|
||||
if (![self running]) {
|
||||
return [[OSGRimeSnapshot alloc] initWithPreedit:@""
|
||||
candidates:@[]
|
||||
commitText:@""
|
||||
composing:NO];
|
||||
}
|
||||
|
||||
RimeApi_stdbool *api = OSGRimeAPI();
|
||||
NSString *commitText = @"";
|
||||
RIME_STRUCT(RimeCommit, commit);
|
||||
if (api->get_commit(self.session, &commit)) {
|
||||
if (commit.text) {
|
||||
commitText = [NSString stringWithUTF8String:commit.text];
|
||||
}
|
||||
api->free_commit(&commit);
|
||||
}
|
||||
|
||||
NSString *preedit = @"";
|
||||
BOOL composing = NO;
|
||||
NSMutableArray<OSGRimeCandidate *> *candidates = [NSMutableArray array];
|
||||
RIME_STRUCT(RimeContext_stdbool, context);
|
||||
if (api->get_context(self.session, &context)) {
|
||||
composing = context.composition.length > 0;
|
||||
if (context.composition.preedit) {
|
||||
preedit = [NSString stringWithUTF8String:context.composition.preedit];
|
||||
}
|
||||
NSInteger count = MIN((NSInteger)context.menu.num_candidates, MAX((NSInteger)0, limit));
|
||||
for (NSInteger index = 0; index < count; index++) {
|
||||
RimeCandidate item = context.menu.candidates[index];
|
||||
NSString *text = item.text ? [NSString stringWithUTF8String:item.text] : @"";
|
||||
NSString *comment = item.comment ? [NSString stringWithUTF8String:item.comment] : @"";
|
||||
[candidates addObject:[[OSGRimeCandidate alloc] initWithText:text comment:comment]];
|
||||
}
|
||||
api->free_context(&context);
|
||||
}
|
||||
|
||||
return [[OSGRimeSnapshot alloc] initWithPreedit:preedit
|
||||
candidates:candidates
|
||||
commitText:commitText
|
||||
composing:composing];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,57 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// Immutable candidate copied out of librime-owned memory.
|
||||
@interface OSGRimeCandidate : NSObject
|
||||
@property(nonatomic, copy, readonly) NSString *text;
|
||||
@property(nonatomic, copy, readonly) NSString *comment;
|
||||
- (instancetype)initWithText:(NSString *)text comment:(NSString *)comment;
|
||||
@end
|
||||
|
||||
/// Immutable runtime snapshot consumed by SwiftUI.
|
||||
@interface OSGRimeSnapshot : NSObject
|
||||
@property(nonatomic, copy, readonly) NSString *preedit;
|
||||
@property(nonatomic, copy, readonly) NSArray<OSGRimeCandidate *> *candidates;
|
||||
@property(nonatomic, copy, readonly) NSString *commitText;
|
||||
@property(nonatomic, readonly) BOOL composing;
|
||||
- (instancetype)initWithPreedit:(NSString *)preedit
|
||||
candidates:(NSArray<OSGRimeCandidate *> *)candidates
|
||||
commitText:(NSString *)commitText
|
||||
composing:(BOOL)composing;
|
||||
@end
|
||||
|
||||
/// Minimal Objective-C++ façade over librime's C API.
|
||||
///
|
||||
/// Callers must serialize access. A bridge owns one session; setup and
|
||||
/// initialization are process-global inside librime.
|
||||
@interface OSGRimeBridge : NSObject
|
||||
|
||||
@property(nonatomic, readonly) BOOL running;
|
||||
|
||||
- (instancetype)initWithSharedDataDirectory:(NSString *)sharedDataDirectory
|
||||
userDataDirectory:(NSString *)userDataDirectory
|
||||
distributionVersion:(NSString *)distributionVersion;
|
||||
|
||||
- (BOOL)start:(NSError * _Nullable * _Nullable)error;
|
||||
- (void)stopSession;
|
||||
- (void)finalizeRuntime;
|
||||
- (BOOL)deployWithFullCheck:(BOOL)fullCheck
|
||||
error:(NSError * _Nullable * _Nullable)error;
|
||||
|
||||
- (BOOL)selectSchema:(NSString *)schemaIdentifier;
|
||||
- (BOOL)setASCIIMode:(BOOL)enabled;
|
||||
- (BOOL)processKeyCode:(int)keyCode modifiers:(int)modifiers;
|
||||
- (BOOL)selectCandidateAtIndex:(NSInteger)index;
|
||||
- (BOOL)commitComposition;
|
||||
- (void)clearComposition;
|
||||
- (NSString *)rawInput;
|
||||
- (OSGRimeSnapshot *)snapshotWithCandidateLimit:(NSInteger)limit;
|
||||
|
||||
@end
|
||||
|
||||
/// X11-compatible key symbols used by librime.
|
||||
FOUNDATION_EXPORT const int OSGRimeKeyBackSpace;
|
||||
FOUNDATION_EXPORT const int OSGRimeKeyReturn;
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
+18
-1
@@ -47,6 +47,7 @@ Voice input for iPhone, iPad, and Mac. Speak in any app — polished text lands
|
||||
| | iOS / iPadOS | macOS |
|
||||
|---|:---:|:---:|
|
||||
| Keyboard / global hotkey | ✅ | ✅ hold Option |
|
||||
| Full / Microsoft / Sogou double pinyin | ✅ optional fuzzy pairs | — |
|
||||
| Local speech recognition | ✅ SpeechAnalyzer | ✅ SenseVoice / Qwen3 |
|
||||
| AI polish | ✅ | ✅ |
|
||||
| Post-polish translation | ✅ | ✅ |
|
||||
@@ -58,7 +59,7 @@ Voice input for iPhone, iPad, and Mac. Speak in any app — polished text lands
|
||||
|
||||
## Privacy
|
||||
|
||||
Speech is transcribed on-device by default. Polish sends **text only** — the transcript and a small amount of nearby cursor text for continuity, never raw audio. Cursor context is not logged or saved to voice history, and secure fields are never captured. See the [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/).
|
||||
Speech is transcribed on-device by default. Polish sends **text only** — the transcript and a small amount of nearby cursor text for continuity, never raw audio. Chinese candidate learning stays in the on-device App Group and is not uploaded. Cursor context is not logged or saved to voice history, and secure fields are never captured. See the [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/).
|
||||
|
||||
---
|
||||
|
||||
@@ -122,6 +123,22 @@ Append a preset in `OSGKeyboardShared/Models/LLMProvider.swift` — any OpenAI-c
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
OSGKeyboard's voice and keyboard features build on these projects and platforms:
|
||||
|
||||
- [Typeless](https://typeless.com) — product inspiration for voice-first input
|
||||
- [Apple SpeechAnalyzer](https://developer.apple.com/documentation/speech) — on-device iOS speech recognition
|
||||
- [librime](https://github.com/rime/librime) and [librime-xcframework](https://github.com/ghostflyby/librime-xcframework) — Chinese input engine and static iOS packaging
|
||||
- [NanoMouse](https://github.com/xjwhnxjwhn/nanomouse) and [Hamster](https://github.com/imfuxiao/Hamster) — references for iOS Rime lifecycle and architecture
|
||||
- [rime-pinyin-simp](https://github.com/rime/rime-pinyin-simp), [Jieba](https://github.com/fxsjy/jieba), [phrase-pinyin-data](https://github.com/mozillazg/phrase-pinyin-data), and [pinyin-data](https://github.com/mozillazg/pinyin-data) — pinyin, phrase, and frequency data
|
||||
- [mlx-audio-swift](https://github.com/Blaizzy/mlx-audio-swift) — local Qwen3 MLX streaming ASR on macOS
|
||||
- [Google Material Icons](https://github.com/google/material-design-icons) — icon font used by the iOS app
|
||||
|
||||
See [Third-Party Notices](./NOTICE-TYPING.md) for exact versions and licenses, or open Settings → About → Third-Party Licenses in the app.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[Source Available License](./LICENSE) — personal, non-commercial use only. Commercial licensing: [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com).
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
| | iOS / iPadOS | macOS |
|
||||
|---|:---:|:---:|
|
||||
| 自定义键盘 / 全局热键 | ✅ | ✅ Option 按住说话 |
|
||||
| 全拼 / 微软双拼 / 搜狗双拼 | ✅ 可选模糊音 | — |
|
||||
| 本地语音识别 | ✅ Apple SpeechAnalyzer | ✅ SenseVoice / Qwen3 |
|
||||
| AI 文本润色 | ✅ | ✅ |
|
||||
| 润色后翻译 | ✅ | ✅ |
|
||||
@@ -62,7 +63,7 @@
|
||||
|
||||
- **默认本地识别** — 录音在设备上转写,不经过我们的服务器
|
||||
- **润色只发文字** — 发给 LLM 的是转写文本,以及用于衔接的少量光标附近文字,不是原始音频
|
||||
- **不记录击键** — 光标上下文仅在润色时临时使用,不写入日志或语音历史;密码框完全不采集
|
||||
- **不上传击键** — 中文候选仅在设备 App Group 中学习词频;普通击键和本地用户词库不会上传,密码框完全不采集
|
||||
- 详见 [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)
|
||||
|
||||
---
|
||||
@@ -97,6 +98,22 @@ open OSGKeyboard.xcodeproj
|
||||
|
||||
---
|
||||
|
||||
## 鸣谢
|
||||
|
||||
OSGKeyboard 的语音与键盘能力建立在这些优秀项目和平台之上:
|
||||
|
||||
- [Typeless](https://typeless.com) — voice-first 输入体验的产品灵感
|
||||
- [Apple SpeechAnalyzer](https://developer.apple.com/documentation/speech) — iOS 端侧语音识别
|
||||
- [librime](https://github.com/rime/librime) 与 [librime-xcframework](https://github.com/ghostflyby/librime-xcframework) — iOS 中文输入引擎及静态框架
|
||||
- [NanoMouse](https://github.com/xjwhnxjwhn/nanomouse) 与 [Hamster](https://github.com/imfuxiao/Hamster) — iOS Rime 生命周期和架构参考
|
||||
- [rime-pinyin-simp](https://github.com/rime/rime-pinyin-simp)、[Jieba](https://github.com/fxsjy/jieba)、[phrase-pinyin-data](https://github.com/mozillazg/phrase-pinyin-data) 与 [pinyin-data](https://github.com/mozillazg/pinyin-data) — 拼音、词语与词频数据
|
||||
- [mlx-audio-swift](https://github.com/Blaizzy/mlx-audio-swift) — macOS 本地 Qwen3 MLX 流式识别
|
||||
- [Google Material Icons](https://github.com/google/material-design-icons) — iOS App 图标字体
|
||||
|
||||
具体版本和许可证见 [第三方许可说明](./NOTICE-TYPING.md),也可在 App 的「设置 → 关于 → 第三方许可」中查看。
|
||||
|
||||
---
|
||||
|
||||
## 许可
|
||||
|
||||
[源码可见许可](./LICENSE) — 个人学习与非商用本地使用;商用请联系 [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com)。
|
||||
@@ -104,5 +121,5 @@ open OSGKeyboard.xcodeproj
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
灵感来自 <a href="https://typeless.com">Typeless</a> · 端侧识别基于 Apple SpeechAnalyzer · Mac 本地模型基于 Sherpa-ONNX
|
||||
开口即文字 · Speak it. It's typed.
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build OSG's commercially permissive Simplified Chinese Rime dictionary.
|
||||
|
||||
Sources are pinned and independently redistributable:
|
||||
- rime-pinyin-simp (Apache-2.0): baseline entries
|
||||
- jieba (MIT): modern word frequencies
|
||||
- phrase-pinyin-data (MIT): phrase pronunciations
|
||||
- pinyin-data (MIT): per-character pronunciation fallback
|
||||
|
||||
The script deliberately does not consume rime-ice, rime-double-pinyin,
|
||||
Luna, or Essay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUTPUT_DIR = ROOT / "OSGKeyboardShared" / "Resources" / "Typing" / "Rime"
|
||||
CACHE_DIR = ROOT / ".cache" / "typing-rime"
|
||||
|
||||
SOURCES = {
|
||||
"pinyin_simp": {
|
||||
"license": "Apache-2.0",
|
||||
"commit": "0c6861ef7420ee780270ca6d993d18d4101049d0",
|
||||
"url": (
|
||||
"https://raw.githubusercontent.com/rime/rime-pinyin-simp/"
|
||||
"0c6861ef7420ee780270ca6d993d18d4101049d0/pinyin_simp.dict.yaml"
|
||||
),
|
||||
},
|
||||
"jieba": {
|
||||
"license": "MIT",
|
||||
"commit": "67fa2e36e72f69d9134b8a1037b83fbb070b9775",
|
||||
"url": (
|
||||
"https://raw.githubusercontent.com/fxsjy/jieba/"
|
||||
"67fa2e36e72f69d9134b8a1037b83fbb070b9775/jieba/dict.txt"
|
||||
),
|
||||
},
|
||||
"phrase_pinyin": {
|
||||
"license": "MIT",
|
||||
"commit": "cee0ed6e6e4898580cafd2bd5e3723e20b214aa0",
|
||||
"url": (
|
||||
"https://raw.githubusercontent.com/mozillazg/phrase-pinyin-data/"
|
||||
"cee0ed6e6e4898580cafd2bd5e3723e20b214aa0/pinyin.txt"
|
||||
),
|
||||
},
|
||||
"character_pinyin": {
|
||||
"license": "MIT",
|
||||
"commit": "923b108dc5d45dee061324c011b478fb649f8b73",
|
||||
"url": (
|
||||
"https://raw.githubusercontent.com/mozillazg/pinyin-data/"
|
||||
"923b108dc5d45dee061324c011b478fb649f8b73/pinyin.txt"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
CJK_RE = re.compile(r"^[\u3400-\u9fff\uf900-\ufaff]+$")
|
||||
|
||||
|
||||
def download(name: str, source: dict[str, str]) -> tuple[Path, str]:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = CACHE_DIR / f"{name}.txt"
|
||||
if not path.exists():
|
||||
request = urllib.request.Request(
|
||||
source["url"],
|
||||
headers={"User-Agent": "OSGKeyboard-rime-builder/1"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=90) as response:
|
||||
path.write_bytes(response.read())
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
return path, digest
|
||||
|
||||
|
||||
def strip_tones(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFD", value.lower())
|
||||
return "".join(
|
||||
char
|
||||
for char in normalized
|
||||
if unicodedata.category(char) != "Mn" and ("a" <= char <= "z" or char == " ")
|
||||
)
|
||||
|
||||
|
||||
def parse_phrase_pinyin(path: Path) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line or line.startswith("#") or ": " not in line:
|
||||
continue
|
||||
phrase, pinyin = line.split(": ", 1)
|
||||
code = " ".join(strip_tones(pinyin).split())
|
||||
if phrase and code:
|
||||
result[phrase] = code
|
||||
return result
|
||||
|
||||
|
||||
def parse_character_pinyin(path: Path) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line or line.startswith("#") or " # " not in line or ": " not in line:
|
||||
continue
|
||||
encoded, character = line.split(" # ", 1)
|
||||
pinyin = encoded.split(": ", 1)[1].split(",", 1)[0]
|
||||
code = strip_tones(pinyin).strip()
|
||||
if character and code:
|
||||
result[character[0]] = code
|
||||
return result
|
||||
|
||||
|
||||
def parse_baseline(path: Path) -> dict[tuple[str, str], int]:
|
||||
entries: dict[tuple[str, str], int] = {}
|
||||
in_body = False
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if line == "...":
|
||||
in_body = True
|
||||
continue
|
||||
if not in_body or not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
text = parts[0]
|
||||
weight = int(parts[-1]) if parts[-1].isdigit() else 1
|
||||
code_parts = parts[1:-1] if parts[-1].isdigit() else parts[1:]
|
||||
code = " ".join(code_parts)
|
||||
entries[(text, code)] = max(entries.get((text, code), 0), weight)
|
||||
return entries
|
||||
|
||||
|
||||
def merge_jieba(
|
||||
path: Path,
|
||||
entries: dict[tuple[str, str], int],
|
||||
phrase_pinyin: dict[str, str],
|
||||
character_pinyin: dict[str, str],
|
||||
) -> tuple[int, int]:
|
||||
accepted = 0
|
||||
inferred = 0
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
parts = line.rsplit(" ", 2)
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
word, frequency, _ = parts
|
||||
if not frequency.isdigit() or not (2 <= len(word) <= 12) or not CJK_RE.fullmatch(word):
|
||||
continue
|
||||
code = phrase_pinyin.get(word)
|
||||
if code is None:
|
||||
syllables = [character_pinyin.get(char) for char in word]
|
||||
if any(item is None for item in syllables):
|
||||
continue
|
||||
code = " ".join(item for item in syllables if item)
|
||||
inferred += 1
|
||||
weight = max(1, int(frequency))
|
||||
key = (word, code)
|
||||
entries[key] = max(entries.get(key, 0), weight)
|
||||
accepted += 1
|
||||
return accepted, inferred
|
||||
|
||||
|
||||
def write_dictionary(entries: dict[tuple[str, str], int]) -> Path:
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
output = OUTPUT_DIR / "osg_pinyin.dict.yaml"
|
||||
header = """# Rime dictionary
|
||||
# encoding: utf-8
|
||||
# Generated by Scripts/typing/build_rime_dictionary.py — DO NOT EDIT.
|
||||
---
|
||||
name: osg_pinyin
|
||||
version: "1.0"
|
||||
sort: by_weight
|
||||
use_preset_vocabulary: false
|
||||
columns:
|
||||
- text
|
||||
- code
|
||||
- weight
|
||||
...
|
||||
"""
|
||||
ordered = sorted(entries.items(), key=lambda item: (item[0][1], -item[1], item[0][0]))
|
||||
body = "".join(f"{text}\t{code}\t{weight}\n" for (text, code), weight in ordered)
|
||||
output.write_text(header + body, encoding="utf-8")
|
||||
return output
|
||||
|
||||
|
||||
def main() -> None:
|
||||
downloaded: dict[str, Path] = {}
|
||||
manifest_sources: dict[str, dict[str, str]] = {}
|
||||
for name, source in SOURCES.items():
|
||||
path, digest = download(name, source)
|
||||
downloaded[name] = path
|
||||
manifest_sources[name] = {**source, "sha256": digest}
|
||||
|
||||
entries = parse_baseline(downloaded["pinyin_simp"])
|
||||
baseline_count = len(entries)
|
||||
phrase_pinyin = parse_phrase_pinyin(downloaded["phrase_pinyin"])
|
||||
character_pinyin = parse_character_pinyin(downloaded["character_pinyin"])
|
||||
jieba_count, inferred_count = merge_jieba(
|
||||
downloaded["jieba"],
|
||||
entries,
|
||||
phrase_pinyin,
|
||||
character_pinyin,
|
||||
)
|
||||
output = write_dictionary(entries)
|
||||
output_digest = hashlib.sha256(output.read_bytes()).hexdigest()
|
||||
|
||||
manifest = {
|
||||
"formatVersion": 1,
|
||||
"sources": manifest_sources,
|
||||
"statistics": {
|
||||
"baselineEntries": baseline_count,
|
||||
"jiebaWordsAccepted": jieba_count,
|
||||
"jiebaWordsUsingCharacterFallback": inferred_count,
|
||||
"outputEntries": len(entries),
|
||||
},
|
||||
"output": {
|
||||
"file": output.name,
|
||||
"sha256": output_digest,
|
||||
},
|
||||
"excluded": [
|
||||
"rime-ice (GPL-3.0)",
|
||||
"rime-double-pinyin (GPL-3.0)",
|
||||
"rime-essay (LGPL-3.0)",
|
||||
"rime-luna-pinyin (LGPL-3.0)",
|
||||
],
|
||||
}
|
||||
(OUTPUT_DIR / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps(manifest["statistics"], ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Combine librime release notices into one in-app readable text file."""
|
||||
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RESOURCE_DIR = ROOT / "OSGKeyboardShared" / "Resources" / "Typing"
|
||||
OUTPUT = RESOURCE_DIR / "LIBRIME-COMBINED-NOTICES.txt"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sections: list[str] = []
|
||||
for name in ("LICENSE.txt", "THIRD_PARTY_NOTICES.md"):
|
||||
path = RESOURCE_DIR / name
|
||||
sections.append(f"{'=' * 72}\n{name}\n{'=' * 72}\n\n{path.read_text(encoding='utf-8')}")
|
||||
|
||||
with ZipFile(RESOURCE_DIR / "third-party-notices.zip") as archive:
|
||||
for name in sorted(item for item in archive.namelist() if item.endswith(".txt")):
|
||||
text = archive.read(name).decode("utf-8", errors="replace")
|
||||
sections.append(f"{'=' * 72}\n{name}\n{'=' * 72}\n\n{text}")
|
||||
|
||||
OUTPUT.write_text("\n\n".join(sections) + "\n", encoding="utf-8")
|
||||
print(f"Wrote {OUTPUT} ({OUTPUT.stat().st_size} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
# Typing Keyboard Baseline (Phase 0)
|
||||
|
||||
Frozen reference for NanoMouse × OSGKeyboard fusion.
|
||||
|
||||
## NanoMouse reference
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| Repo | https://github.com/xjwhnxjwhn/nanomouse |
|
||||
| Frozen commit | `a6177d898a01662ce551b43a01cf82a9f84ca54c` (2026-07-29 tip at clone) |
|
||||
| Local clone (gitignored) | `.refs/nanomouse/` |
|
||||
|
||||
## KeyboardKit / key shell
|
||||
|
||||
NanoMouse does **not** SPM-pin upstream KeyboardKit Pro. It vendors an MIT KeyboardKit tree inside:
|
||||
|
||||
`ios/Packages/HamsterKeyboardKit/Sources/KeyboardKit/` (see that folder’s `LICENSE`).
|
||||
|
||||
OSG ships a **lean SwiftUI key shell** in-repo (`OSGKeyboardExt/Typing/`)
|
||||
behind `TypingLayoutProviding`; KeyboardKit is not linked.
|
||||
|
||||
**KeyboardKit Pro is not used.**
|
||||
|
||||
## Chinese schema / lexicon
|
||||
|
||||
| Choice | Detail |
|
||||
|--------|--------|
|
||||
| Product intent | Full pinyin + Microsoft/Sogou double pinyin + opt-in fuzzy pairs |
|
||||
| Engine | librime 1.17.0 via static XCFramework (BSD-3-Clause) |
|
||||
| Binary package | `ghostflyby/librime-xcframework` `1.17.0-pack.1`, checksum `0f0fc13b…1164` |
|
||||
| Baseline | rime-pinyin-simp (Apache-2.0) |
|
||||
| Modern words | Jieba frequencies + phrase-pinyin-data + pinyin-data (MIT) |
|
||||
| Generated lexicon | `Resources/Typing/Rime/osg_pinyin.dict.yaml` (~365K entries) |
|
||||
| User learning | librime userdb in App Group |
|
||||
| Explicitly excluded | rime-ice / rime-double-pinyin (GPL), Luna / Essay (LGPL) |
|
||||
|
||||
The dictionary is rebuilt deterministically by
|
||||
`Scripts/typing/build_rime_dictionary.py`; `manifest.json` pins every source
|
||||
commit and SHA-256.
|
||||
|
||||
## Memory / height budget
|
||||
|
||||
| Mode | Target height | Memory notes |
|
||||
|------|---------------|--------------|
|
||||
| Voice | 281 pt (`KeyboardRootView.totalHeight`) | Matches typing height; no typing engine loaded |
|
||||
| Typing | 281 pt (`TypingRootView.totalHeight`) | Host-prebuilt Rime data; extension opens one session |
|
||||
| RSS goal | Typing peak < 50 MB | Session closes on voice switch / memory warning |
|
||||
|
||||
## Success criteria (Phase 1)
|
||||
|
||||
- Top-right tab switches voice ↔ typing; recording/processing locks voice.
|
||||
- English QWERTY + 123 / basic symbols.
|
||||
- Full pinyin and Microsoft/Sogou double pinyin produce phrase candidates.
|
||||
- Fuzzy pairs default off and are enabled individually in Settings.
|
||||
- No GPL/LGPL input data in the app; NOTICE lists exact source licenses.
|
||||
+2
-1
@@ -35,13 +35,14 @@
|
||||
<li><strong>API credentials</strong> — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults. When you enable <strong>iCloud settings sync</strong>, API keys replicate through Apple's <strong>iCloud Keychain</strong> to your other signed-in devices — not through iCloud Key-Value Store JSON.</li>
|
||||
<li><strong>App preferences</strong> — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group <code>UserDefaults</code> on your device so the main app and keyboard extension stay in sync. When iCloud settings sync is enabled, these preferences (excluding API keys) may also be mirrored in your private iCloud Key-Value Store account.</li>
|
||||
<li><strong>Personal dictionary</strong> — terms and aliases you add in the Dictionary tab are stored locally on your device. They are included in LLM polish prompts so your vocabulary is preserved. Optional <strong>iCloud dictionary sync</strong> mirrors your dictionary through your private iCloud Key-Value Store; OSGKeyboard does not operate a separate dictionary server.</li>
|
||||
<li><strong>On-device typing learning</strong> — the Chinese keyboard stores selected words and candidate frequencies in the App Group on your device so future candidates improve. This user dictionary is not uploaded by OSGKeyboard.</li>
|
||||
<li><strong>Voice history</strong> — the host app may keep a list of recent successful transcripts in its History tab. History is capped at 300 entries. When iCloud settings sync is enabled, history may also sync through your private iCloud Key-Value Store.</li>
|
||||
<li><strong>Usage statistics</strong> — cumulative dictation time, dictation characters, and translation characters are computed for the home screen stats card. When iCloud settings sync is enabled, per-device totals may merge through your private iCloud Key-Value Store.</li>
|
||||
</ul>
|
||||
|
||||
<h2>What we do not collect</h2>
|
||||
<ul>
|
||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes you type with the keyboard.</li>
|
||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.</li>
|
||||
<li>We do <strong>not</strong> operate analytics, crash reporting, or advertising SDKs.</li>
|
||||
<li>We do <strong>not</strong> upload raw audio anywhere on the default local engine. The only exception is the optional cloud ASR engine: if you explicitly enable it (a confirmation dialog is shown first), recordings go to the ASR provider <em>you</em> configure — never to servers of ours.</li>
|
||||
<li>We do <strong>not</strong> sell personal data.</li>
|
||||
|
||||
@@ -35,13 +35,14 @@
|
||||
<li><strong>API credentials</strong> — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults.</li>
|
||||
<li><strong>App preferences</strong> — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group <code>UserDefaults</code> on your device so the main app and keyboard extension stay in sync.</li>
|
||||
<li><strong>Personal dictionary</strong> — terms and aliases you add in the Dictionary tab are stored locally on your device. They are included in LLM polish prompts so your vocabulary is preserved; dictionary data is not uploaded to a separate server.</li>
|
||||
<li><strong>On-device typing learning</strong> — the Chinese keyboard stores selected words and candidate frequencies in the App Group on your device so future candidates improve. This user dictionary is not uploaded by OSGKeyboard.</li>
|
||||
<li><strong>Voice history</strong> — the host app may keep a list of recent successful transcripts in its History tab. History is capped at 300 entries. When iCloud settings sync is enabled, history may also sync through your private iCloud Key-Value Store.</li>
|
||||
<li><strong>Usage statistics</strong> — cumulative dictation time, dictation characters, translation characters, and dictionary entry count are computed and stored locally on the home screen stats card.</li>
|
||||
</ul>
|
||||
|
||||
<h2>What we do not collect</h2>
|
||||
<ul>
|
||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes you type with the keyboard.</li>
|
||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.</li>
|
||||
<li>We do <strong>not</strong> operate analytics, crash reporting, or advertising SDKs.</li>
|
||||
<li>We do <strong>not</strong> upload raw audio anywhere on the default local engine. The only exception is the optional cloud ASR engine: if you explicitly enable it (a confirmation dialog is shown first), recordings go to the ASR provider <em>you</em> configure — never to servers of ours.</li>
|
||||
<li>We do <strong>not</strong> sell personal data.</li>
|
||||
|
||||
+25
-3
@@ -32,6 +32,9 @@ options:
|
||||
packages:
|
||||
MLXAudio:
|
||||
path: ThirdParty/mlx-audio-swift
|
||||
Librime:
|
||||
url: https://github.com/ghostflyby/librime-xcframework.git
|
||||
exactVersion: 1.17.0-pack.1
|
||||
|
||||
settings:
|
||||
base:
|
||||
@@ -48,8 +51,8 @@ settings:
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
|
||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "1.1"
|
||||
CURRENT_PROJECT_VERSION: "32"
|
||||
MARKETING_VERSION: "1.5.0"
|
||||
CURRENT_PROJECT_VERSION: "33"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
@@ -233,6 +236,7 @@ targets:
|
||||
- path: OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
|
||||
buildPhase: resources
|
||||
resources:
|
||||
- path: OSGKeyboardExt/Assets.xcassets
|
||||
- path: OSGKeyboardExt/PrivacyInfo.xcprivacy
|
||||
settings:
|
||||
base:
|
||||
@@ -312,7 +316,6 @@ targets:
|
||||
- sdk: SwiftUI.framework
|
||||
- sdk: ActivityKit.framework
|
||||
|
||||
# =========================================================
|
||||
# Shared Framework (主 App 与扩展共用)
|
||||
# =========================================================
|
||||
OSGKeyboardShared:
|
||||
@@ -328,10 +331,24 @@ targets:
|
||||
buildPhase: resources
|
||||
- path: OSGKeyboardShared/zh-Hans.lproj/Shared.strings
|
||||
buildPhase: resources
|
||||
- path: OSGRimeBridge
|
||||
excludes:
|
||||
- "include/**"
|
||||
resources:
|
||||
- path: OSGKeyboardShared/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin
|
||||
- path: OSGKeyboardShared/Resources/CustomLanguageModel/v1/compiled-manifest.json
|
||||
- path: OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
|
||||
- path: OSGKeyboardShared/Resources/Typing/NOTICE.txt
|
||||
- path: OSGKeyboardShared/Resources/Typing/LICENSE.txt
|
||||
- path: OSGKeyboardShared/Resources/Typing/LICENSE-PINYIN-SIMP-APACHE.txt
|
||||
- path: OSGKeyboardShared/Resources/Typing/LICENSE-JIEBA-MIT.txt
|
||||
- path: OSGKeyboardShared/Resources/Typing/LICENSE-PHRASE-PINYIN-DATA-MIT.txt
|
||||
- path: OSGKeyboardShared/Resources/Typing/LICENSE-PINYIN-DATA-MIT.txt
|
||||
- path: OSGKeyboardShared/Resources/Typing/THIRD_PARTY_NOTICES.md
|
||||
- path: OSGKeyboardShared/Resources/Typing/third-party-notices.zip
|
||||
- path: OSGKeyboardShared/Resources/Typing/LIBRIME-COMBINED-NOTICES.txt
|
||||
- path: OSGKeyboardShared/Resources/Typing/Rime/osg_pinyin.dict.yaml
|
||||
- path: OSGKeyboardShared/Resources/Typing/Rime/manifest.json
|
||||
info:
|
||||
path: OSGKeyboardShared/Info.plist
|
||||
settings:
|
||||
@@ -343,6 +360,9 @@ targets:
|
||||
BUILD_LIBRARY_FOR_DISTRIBUTION: NO
|
||||
DYLIB_INSTALL_NAME_BASE: "@rpath"
|
||||
APPLICATION_EXTENSION_API_ONLY: YES
|
||||
SWIFT_OBJC_BRIDGING_HEADER: OSGRimeBridge/include/OSGRimeBridge.h
|
||||
CLANG_ENABLE_MODULES: YES
|
||||
OTHER_CPLUSPLUSFLAGS: "$(inherited) -fmodules -fcxx-modules"
|
||||
ENABLE_MODULE_VERIFIER: YES
|
||||
# Embedded frameworks are re-signed by the containing app at package
|
||||
# time. Signing this framework as a standalone build product is noisy
|
||||
@@ -351,6 +371,8 @@ targets:
|
||||
CODE_SIGNING_REQUIRED: NO
|
||||
CODE_SIGN_IDENTITY: ""
|
||||
dependencies:
|
||||
- package: Librime
|
||||
product: RimeStatic
|
||||
- sdk: Speech.framework
|
||||
- sdk: AVFoundation.framework
|
||||
- sdk: Charts.framework
|
||||
|
||||
Reference in New Issue
Block a user