feat(keyboard): add Notes skill and system-style typing
Add save-to-Notes export and direct map handoff while aligning multi-touch typing, period shortcuts, return actions, and navigation visuals with system behavior.
This commit is contained in:
@@ -25,6 +25,8 @@ public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable {
|
||||
|
||||
public enum AIAgentShortcutRun {
|
||||
public static let pendingKey = "config.aiAgentSkills.pendingRun.v1"
|
||||
/// Last skill-handoff lines, readable from the App Group plist on device.
|
||||
public static let recentTracesKey = "diag.skills.recent.v1"
|
||||
/// Drop payloads older than this; a leftover write must not fire later.
|
||||
public static let payloadTTL: TimeInterval = 60
|
||||
|
||||
@@ -65,6 +67,19 @@ public enum AIAgentShortcutRun {
|
||||
/// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies.
|
||||
public static func trace(_ message: String) {
|
||||
OSGDiag.log(message, category: "skills")
|
||||
persistTrace(message)
|
||||
}
|
||||
|
||||
/// Keep a short ring so we can copy traces off the phone without root `log collect`.
|
||||
private static func persistTrace(_ message: String) {
|
||||
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
||||
var lines = defaults.stringArray(forKey: recentTracesKey) ?? []
|
||||
let stamp = ISO8601DateFormatter().string(from: Date())
|
||||
lines.append("\(stamp) \(message)")
|
||||
if lines.count > 60 {
|
||||
lines = Array(lines.suffix(60))
|
||||
}
|
||||
defaults.set(lines, forKey: recentTracesKey)
|
||||
}
|
||||
|
||||
/// Single-line preview so Console keeps the format (`\\n` for newlines).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
|
||||
// Settings / the Skills tab can persist a subset or permutation without
|
||||
// changing the view. Transform skills insert into the current field;
|
||||
// export skills hand off to a companion Shortcut after the model runs.
|
||||
// export skills hand off to the host after the model runs (Shortcut, Maps, or Didi).
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -39,7 +39,9 @@ public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
|
||||
/// Built-in skills are always false. Custom skills default off.
|
||||
public let thinkingEnabled: Bool
|
||||
|
||||
public var requiresShortcut: Bool { kind == .export }
|
||||
/// Reminders, Calendar, and Notes exports need a companion Shortcut.
|
||||
/// Navigate and Ride hand off to the host (Maps or Didi). No Shortcut.
|
||||
public var requiresShortcut: Bool { kind == .export && shortcutName != nil }
|
||||
public var isUserCreated: Bool { id.hasPrefix("user.") }
|
||||
|
||||
public init(
|
||||
@@ -80,22 +82,18 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
public static let summarizeID = "summarize"
|
||||
public static let translateID = "translate"
|
||||
public static let extractTodosID = "extractTodos"
|
||||
public static let extractTodosShortcutName = "OSG · 提取待办"
|
||||
public static let extractTodosShortcutICloudURL = URL(
|
||||
string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
|
||||
)!
|
||||
public static let extractTodosShortcutName = "OSGExtractTodos"
|
||||
public static let extractTodosResourceName = "OSGExtractTodos"
|
||||
|
||||
public static let extractEventsID = "extractEvents"
|
||||
public static let extractEventsShortcutName = "OSG · 提取日程"
|
||||
public static let extractEventsShortcutICloudURL = URL(
|
||||
string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf"
|
||||
)!
|
||||
public static let extractEventsShortcutName = "OSGExtractEvents"
|
||||
public static let extractEventsResourceName = "OSGExtractEvents"
|
||||
|
||||
public static let saveToNotesID = "saveToNotes"
|
||||
public static let saveToNotesShortcutName = "OSGSaveToNotes"
|
||||
public static let saveToNotesResourceName = "OSGSaveToNotes"
|
||||
|
||||
public static let navigateID = "navigate"
|
||||
public static let navigateShortcutName = "OSG · 导航"
|
||||
public static let navigateResourceName = "OSGNavigate"
|
||||
|
||||
/// Full built-in catalog, in a stable display order for the Skills tab.
|
||||
public static let catalog: [AIClipboardSkill] = [
|
||||
@@ -135,7 +133,6 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
shortcutName: extractTodosShortcutName,
|
||||
shortcutICloudURL: extractTodosShortcutICloudURL,
|
||||
shortcutResourceName: extractTodosResourceName
|
||||
),
|
||||
AIClipboardSkill(
|
||||
@@ -147,9 +144,19 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
shortcutName: extractEventsShortcutName,
|
||||
shortcutICloudURL: extractEventsShortcutICloudURL,
|
||||
shortcutResourceName: extractEventsResourceName
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: saveToNotesID,
|
||||
systemImage: "note.text",
|
||||
titleKey: "keyboard.ai.skill.saveToNotes",
|
||||
cardTitleKey: "skills.saveToNotes.name",
|
||||
descriptionKey: "skills.saveToNotes.description",
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
shortcutName: saveToNotesShortcutName,
|
||||
shortcutResourceName: saveToNotesResourceName
|
||||
),
|
||||
AIClipboardSkill(
|
||||
id: navigateID,
|
||||
systemImage: "arrow.triangle.turn.up.right.diamond.fill",
|
||||
@@ -157,9 +164,7 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
cardTitleKey: "skills.navigate.name",
|
||||
descriptionKey: "skills.navigate.description",
|
||||
kind: .export,
|
||||
isDefault: false,
|
||||
shortcutName: navigateShortcutName,
|
||||
shortcutResourceName: navigateResourceName
|
||||
isDefault: false
|
||||
),
|
||||
]
|
||||
|
||||
@@ -263,6 +268,8 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
"""
|
||||
case extractEventsID:
|
||||
return eventInstruction(zh: zh, now: now)
|
||||
case saveToNotesID:
|
||||
return noteInstruction(zh: zh, now: now)
|
||||
case navigateID:
|
||||
return navigateInstruction(zh: zh)
|
||||
default:
|
||||
@@ -272,6 +279,26 @@ public enum AIClipboardSkillCatalog: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Title only. The original clipboard is the note body; do not ask the
|
||||
/// model to rewrite it.
|
||||
private static func noteInstruction(zh: Bool, now: Date) -> String {
|
||||
let clock = clockContext(now: now, zh: zh)
|
||||
if zh {
|
||||
return """
|
||||
\(clock)
|
||||
请根据剪贴板正文写一个简短备忘录标题。只要一行标题,不要输出正文,不要编号、不要引号、不要解释。标题中不要出现换行或 |。最多 40 个字。
|
||||
标题应能让人在列表里认出这篇笔记,可结合今天的日期或时间(例如「8月13日周会纪要」)。不要改写或重复正文。
|
||||
即使原文很短也要给一个标题。不要输出 NONE。
|
||||
"""
|
||||
}
|
||||
return """
|
||||
\(clock)
|
||||
Write a short Notes title from the clipboard. One line only; do not output the body. No numbering, quotes, or commentary. No newlines or | in the title. Maximum 40 characters.
|
||||
The title should identify the note in a list and may include today's date or time (for example "13 Aug standup notes"). Do not rewrite or repeat the body.
|
||||
Always return a title, even when the clipboard is short. Do not output NONE.
|
||||
"""
|
||||
}
|
||||
|
||||
private static func navigateInstruction(zh: Bool) -> String {
|
||||
if zh {
|
||||
return """
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Line-oriented Shortcut input for user-created export skills. Built-in
|
||||
// extract-todos / extract-events keep their dedicated parsers.
|
||||
// extract-todos / extract-events / save-to-notes keep their dedicated parsers.
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Builds one turn-by-turn URL. Provider order is Amap (高德) → Baidu →
|
||||
// Apple Maps. Shortcuts cannot call `canOpenURL`, so the host injects that
|
||||
// check and passes the resulting URL to the companion Shortcut.
|
||||
// Apple Maps. The host calls `canOpenURL` and opens the URL itself.
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// AINoteExport.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Builds one Notes payload: generated title + original clipboard body,
|
||||
// joined by `fieldSeparator` so the companion Shortcut can combine them
|
||||
// as first-line title + body. Fail open: a missing or unusable model title
|
||||
// falls back to a dated snippet so the paste still lands in Notes.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AINoteExport: Sendable {
|
||||
public static let maximumTitleLength = 40
|
||||
public static let maximumSnippetLength = 24
|
||||
/// Split token for the companion Shortcut: title, then original body.
|
||||
/// Newlines stay inside the body, so do not join with `\n`.
|
||||
/// Avoid `<>` — `shortcuts://…&text=` treats angle brackets like tags and
|
||||
/// drops the payload (todos/events work because they only use `|` / newlines).
|
||||
public static let fieldSeparator = "||OSG_NOTE||"
|
||||
|
||||
private static let emptyTokens: Set<String> = [
|
||||
"none", "no", "n/a", "na", "nil", "null",
|
||||
"无", "没有", "没有标题", "无标题",
|
||||
"no title", "no note", "no notes",
|
||||
]
|
||||
|
||||
/// One string for Shortcuts: `title||OSG_NOTE||body`. Empty → do not run it.
|
||||
public static func items(
|
||||
from answer: String,
|
||||
sourceClipboard: String?,
|
||||
now: Date = Date(),
|
||||
locale: String = "zh",
|
||||
calendar: Calendar = .current
|
||||
) -> [String] {
|
||||
let body = sourceClipboard?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !body.isEmpty else { return [] }
|
||||
let title = resolvedTitle(
|
||||
from: answer,
|
||||
body: body,
|
||||
now: now,
|
||||
locale: locale,
|
||||
calendar: calendar
|
||||
)
|
||||
return ["\(title)\(fieldSeparator)\(body)"]
|
||||
}
|
||||
|
||||
// MARK: - Title
|
||||
|
||||
private static func resolvedTitle(
|
||||
from answer: String,
|
||||
body: String,
|
||||
now: Date,
|
||||
locale: String,
|
||||
calendar: Calendar
|
||||
) -> String {
|
||||
if let title = parsedTitle(answer),
|
||||
!isWholeClipboardEcho(title, source: body) {
|
||||
return truncate(title, maximumTitleLength)
|
||||
}
|
||||
return fallbackTitle(body: body, now: now, locale: locale, calendar: calendar)
|
||||
}
|
||||
|
||||
private static func parsedTitle(_ raw: String) -> String? {
|
||||
let first = raw
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: .newlines)
|
||||
.first ?? ""
|
||||
var title = stripBullet(first)
|
||||
title = stripWrappingQuotes(title)
|
||||
if let pipe = title.firstIndex(of: "|") {
|
||||
title = String(title[..<pipe])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
guard !title.isEmpty, !isEmptyToken(title) else { return nil }
|
||||
return title
|
||||
}
|
||||
|
||||
private static func fallbackTitle(
|
||||
body: String,
|
||||
now: Date,
|
||||
locale: String,
|
||||
calendar: Calendar
|
||||
) -> String {
|
||||
let stamp = dateStamp(now, locale: locale, calendar: calendar)
|
||||
let snippet = truncate(firstLine(body), maximumSnippetLength)
|
||||
if snippet.isEmpty { return stamp }
|
||||
return "\(stamp) · \(snippet)"
|
||||
}
|
||||
|
||||
private static func dateStamp(_ now: Date, locale: String, calendar: Calendar) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.locale = Locale(identifier: locale == "zh" ? "zh_CN" : "en_US_POSIX")
|
||||
formatter.dateFormat = locale == "zh" ? "M月d日" : "d MMM"
|
||||
return formatter.string(from: now)
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
private static func firstLine(_ text: String) -> String {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: .newlines)
|
||||
.first?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
private static func truncate(_ text: String, _ limit: Int) -> String {
|
||||
guard text.count > limit else { return text }
|
||||
return String(text.prefix(limit))
|
||||
}
|
||||
|
||||
private static func isEmptyToken(_ text: String) -> Bool {
|
||||
let folded = text
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "。.!!"))
|
||||
.lowercased()
|
||||
return emptyTokens.contains(folded)
|
||||
}
|
||||
|
||||
private static func stripBullet(_ line: String) -> String {
|
||||
var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let prefixes = ["- ", "* ", "• ", "、"]
|
||||
for prefix in prefixes where text.hasPrefix(prefix) {
|
||||
text = String(text.dropFirst(prefix.count))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
|
||||
text = String(text[dotted.upperBound...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private static func stripWrappingQuotes(_ line: String) -> String {
|
||||
let pairs: [(Character, Character)] = [
|
||||
("\"", "\""),
|
||||
("“", "”"),
|
||||
("「", "」"),
|
||||
("『", "』"),
|
||||
("'", "'"),
|
||||
("‘", "’"),
|
||||
]
|
||||
var text = line
|
||||
for (open, close) in pairs where text.count >= 2 {
|
||||
if text.first == open, text.last == close {
|
||||
text = String(text.dropFirst().dropLast())
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/// A title that is essentially the whole clipboard is not a title.
|
||||
private static func isWholeClipboardEcho(_ title: String, source: String) -> Bool {
|
||||
guard source.count > 80, title.count > 80 else { return false }
|
||||
let a = collapse(title)
|
||||
let b = collapse(source)
|
||||
guard !a.isEmpty, !b.isEmpty else { return false }
|
||||
if a == b { return true }
|
||||
return a.contains(b) || b.contains(a)
|
||||
}
|
||||
|
||||
private static func collapse(_ text: String) -> String {
|
||||
text.components(separatedBy: .whitespacesAndNewlines)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,9 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
|
||||
/// Export skill currently waiting on the LLM. Nil for transform skills.
|
||||
@Published public var pendingClipboardSkillID: String?
|
||||
/// Clipboard captured when that export skill was tapped, so the body
|
||||
/// still exists after the 30-second hint window closes.
|
||||
public var pendingClipboardSkillSource: String?
|
||||
/// In-keyboard toast (e.g. no todos). Does not leave the host app.
|
||||
@Published public var skillTipText: String?
|
||||
/// Host field is a password / secure entry — never read pasteboard.
|
||||
@@ -219,13 +222,38 @@ public final class KeyboardState: ObservableObject {
|
||||
public enum ReturnKeyRole: Equatable {
|
||||
case newline
|
||||
case send
|
||||
case go
|
||||
case search
|
||||
case join
|
||||
case done
|
||||
case next
|
||||
case `continue`
|
||||
case route
|
||||
case google
|
||||
case yahoo
|
||||
case emergencyCall
|
||||
|
||||
public var titleKey: String {
|
||||
switch self {
|
||||
case .newline: return "common.newline"
|
||||
case .send: return "common.send"
|
||||
case .send: return "common.send"
|
||||
case .go: return "keyboard.return.go"
|
||||
case .search: return "keyboard.return.search"
|
||||
case .join: return "keyboard.return.join"
|
||||
case .done: return "common.done"
|
||||
case .next: return "keyboard.return.next"
|
||||
case .continue: return "common.continue"
|
||||
case .route: return "keyboard.return.route"
|
||||
case .google: return "keyboard.return.google"
|
||||
case .yahoo: return "keyboard.return.yahoo"
|
||||
case .emergencyCall: return "keyboard.return.emergencyCall"
|
||||
}
|
||||
}
|
||||
|
||||
/// Green action chrome (system uses blue for Go / Search / Send / Done).
|
||||
public var usesActionFill: Bool {
|
||||
self != .newline
|
||||
}
|
||||
}
|
||||
|
||||
// Action hooks — injected by the view controller at install time.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// PeriodShortcut.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// iOS "." Shortcut: a second Space shortly after a Space that follows a
|
||||
// word character becomes ". " and arms sentence Shift.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum PeriodShortcut: Sendable {
|
||||
/// Window for the second Space tap. Slow consecutive spaces stay spaces.
|
||||
public static let doubleTapInterval: TimeInterval = 0.45
|
||||
|
||||
/// Whether `precedingText` (already including the first space) can take
|
||||
/// the shortcut: `…X ` where X is a letter or number, not a terminator.
|
||||
public static func shouldReplacePreviousSpace(precedingText: String) -> Bool {
|
||||
guard precedingText.last == " " else { return false }
|
||||
guard let previous = precedingText.dropLast().last else { return false }
|
||||
if previous.isWhitespace || previous.isNewline { return false }
|
||||
return previous.isLetter || previous.isNumber
|
||||
}
|
||||
|
||||
/// After inserting a space, arm only when that space followed a word char.
|
||||
public static func shouldArm(afterSpaceFollowing precedingBeforeSpace: String) -> Bool {
|
||||
guard let last = precedingBeforeSpace.last else { return false }
|
||||
if last.isWhitespace || last.isNewline { return false }
|
||||
return last.isLetter || last.isNumber
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
/// (common in Notes). Capped; reseeds from the proxy when it looks fresh.
|
||||
private var precedingShadow = ""
|
||||
private static let precedingShadowLimit = 400
|
||||
/// iOS "." Shortcut: second Space shortly after a Space that followed a word.
|
||||
private var periodShortcutArmed = false
|
||||
private var lastSpaceAt: Date?
|
||||
|
||||
public init(
|
||||
engine: (@MainActor () -> RimeEngineBridging)? = nil,
|
||||
@@ -166,6 +169,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
page = .letters
|
||||
resetShiftState()
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
clearPeriodShortcut()
|
||||
// Drop English lexicon pages when leaving typing (jetsam recovery).
|
||||
EnglishLexicon.shared.unload()
|
||||
englishStorage = nil
|
||||
@@ -202,6 +206,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
engine.setLanguage(newLanguage)
|
||||
page = .letters
|
||||
isCandidatePanelExpanded = false
|
||||
clearPeriodShortcut()
|
||||
if newLanguage == .english {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
refreshPersonalTerms()
|
||||
@@ -235,6 +240,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
public func setPage(_ page: TypingKeyPage) {
|
||||
self.page = page
|
||||
resetShiftState()
|
||||
clearPeriodShortcut()
|
||||
if page == .letters {
|
||||
syncAutocapitalization()
|
||||
}
|
||||
@@ -264,6 +270,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
/// Handle a visible key label.
|
||||
public func handleKey(_ label: String) -> TypingOutput {
|
||||
clearPeriodShortcut()
|
||||
switch label {
|
||||
case "⇧":
|
||||
// Tests / non-gesture callers: same as a completed Shift tap.
|
||||
@@ -317,8 +324,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
public func handleSpace() -> TypingOutput {
|
||||
if language == .english {
|
||||
return commitEnglishWord(suffix: " ")
|
||||
return handleEnglishSpace()
|
||||
}
|
||||
clearPeriodShortcut()
|
||||
let text = engine.processSpace() ?? " "
|
||||
composition = engine.composition
|
||||
syncCandidatePanelVisibility()
|
||||
@@ -326,6 +334,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
public func handleReturn() -> TypingOutput {
|
||||
clearPeriodShortcut()
|
||||
if language == .english {
|
||||
return commitEnglishWord(suffix: "\n")
|
||||
}
|
||||
@@ -336,6 +345,7 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
public func selectCandidate(at index: Int) -> TypingOutput {
|
||||
clearPeriodShortcut()
|
||||
if language == .english {
|
||||
return selectEnglishCandidate(at: index)
|
||||
}
|
||||
@@ -352,6 +362,38 @@ public final class TypingSessionController: ObservableObject {
|
||||
|
||||
// MARK: - English
|
||||
|
||||
private func handleEnglishSpace() -> TypingOutput {
|
||||
let preceding = precedingTextForShortcut()
|
||||
if periodShortcutArmed,
|
||||
let stamped = lastSpaceAt,
|
||||
Date().timeIntervalSince(stamped) <= PeriodShortcut.doubleTapInterval,
|
||||
PeriodShortcut.shouldReplacePreviousSpace(precedingText: preceding) {
|
||||
clearPeriodShortcut()
|
||||
let wordOut = commitEnglishWord(suffix: "")
|
||||
let deleteCount = wordOut.deleteCount + 1
|
||||
return TypingOutput(deleteCount: deleteCount, text: wordOut.text + ". ")
|
||||
}
|
||||
|
||||
let output = commitEnglishWord(suffix: " ")
|
||||
if PeriodShortcut.shouldArm(afterSpaceFollowing: preceding) {
|
||||
periodShortcutArmed = true
|
||||
lastSpaceAt = Date()
|
||||
} else {
|
||||
clearPeriodShortcut()
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private func precedingTextForShortcut() -> String {
|
||||
if !precedingShadow.isEmpty { return precedingShadow }
|
||||
return precedingTextProvider?() ?? ""
|
||||
}
|
||||
|
||||
private func clearPeriodShortcut() {
|
||||
periodShortcutArmed = false
|
||||
lastSpaceAt = nil
|
||||
}
|
||||
|
||||
private func handleEnglishCharacter(_ ch: Character) -> TypingOutput {
|
||||
pendingAutocorrection = nil
|
||||
if ch.isLetter {
|
||||
@@ -546,6 +588,9 @@ public final class TypingSessionController: ObservableObject {
|
||||
/// briefly reports the immediately preceding edit (common in Notes).
|
||||
public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) {
|
||||
guard language == .english else { return }
|
||||
if caretMoved {
|
||||
clearPeriodShortcut()
|
||||
}
|
||||
guard suggestionsEnabled else {
|
||||
clearEnglishWordState(keepPrevious: false)
|
||||
composition = .empty
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// TypingTouchTracker.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Multi-finger typing contract (system-keyboard overlap):
|
||||
// each finger is independent; a new key-down commits any other pending
|
||||
// character/space/return so press order, not release order, wins.
|
||||
// Shift can be held with one finger while another types.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Per-step side effects for the UIKit touch pad to apply.
|
||||
public struct TypingTouchEffects: Equatable, Sendable {
|
||||
public var commits: [TypingKeyHitTarget] = []
|
||||
public var playFeedback: TypingKeyHitTarget?
|
||||
public var deleteFire = false
|
||||
public var startDeleteRepeat = false
|
||||
public var stopDeleteRepeat = false
|
||||
public var beginShift = false
|
||||
public var endShift = false
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
/// Pure multi-touch state machine. IDs are `ObjectIdentifier` of `UITouch`
|
||||
/// in the extension, or any unique object in tests.
|
||||
public final class TypingTouchTracker {
|
||||
private struct Finger {
|
||||
let id: ObjectIdentifier
|
||||
var key: TypingKeyHitTarget?
|
||||
var committed: Bool
|
||||
var ownsShift: Bool
|
||||
var ownsDeleteRepeat: Bool
|
||||
let order: UInt64
|
||||
}
|
||||
|
||||
private var fingers: [ObjectIdentifier: Finger] = [:]
|
||||
private var nextOrder: UInt64 = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
public var highlightedKeyIDs: Set<String> {
|
||||
Set(
|
||||
fingers.values.compactMap { finger in
|
||||
guard !finger.committed else { return nil }
|
||||
return finger.key?.id
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public func began(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
|
||||
var effects = TypingTouchEffects()
|
||||
|
||||
if let key, hasUncommittedFinger(on: key.id) {
|
||||
return effects
|
||||
}
|
||||
|
||||
if key != nil {
|
||||
commitPendingCharacterKeys(into: &effects)
|
||||
stopForeignDeleteRepeats(into: &effects)
|
||||
}
|
||||
|
||||
var finger = Finger(
|
||||
id: id,
|
||||
key: key,
|
||||
committed: false,
|
||||
ownsShift: false,
|
||||
ownsDeleteRepeat: false,
|
||||
order: nextOrder
|
||||
)
|
||||
nextOrder += 1
|
||||
|
||||
if let key {
|
||||
effects.playFeedback = key
|
||||
activate(key, on: &finger, effects: &effects)
|
||||
}
|
||||
fingers[id] = finger
|
||||
return effects
|
||||
}
|
||||
|
||||
public func moved(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
|
||||
guard var finger = fingers[id], !finger.committed else {
|
||||
return TypingTouchEffects()
|
||||
}
|
||||
if finger.key?.id == key?.id {
|
||||
return TypingTouchEffects()
|
||||
}
|
||||
|
||||
var effects = TypingTouchEffects()
|
||||
if finger.ownsDeleteRepeat {
|
||||
effects.stopDeleteRepeat = true
|
||||
finger.ownsDeleteRepeat = false
|
||||
}
|
||||
|
||||
finger.key = key
|
||||
if let key {
|
||||
effects.playFeedback = key
|
||||
activate(key, on: &finger, effects: &effects)
|
||||
}
|
||||
fingers[id] = finger
|
||||
return effects
|
||||
}
|
||||
|
||||
public func ended(id: ObjectIdentifier, key: TypingKeyHitTarget?) -> TypingTouchEffects {
|
||||
guard let finger = fingers.removeValue(forKey: id) else {
|
||||
return TypingTouchEffects()
|
||||
}
|
||||
return finish(finger, hit: key, commitIfNeeded: true)
|
||||
}
|
||||
|
||||
public func cancelled(id: ObjectIdentifier) -> TypingTouchEffects {
|
||||
guard let finger = fingers.removeValue(forKey: id) else {
|
||||
return TypingTouchEffects()
|
||||
}
|
||||
return finish(finger, hit: nil, commitIfNeeded: false)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func activate(
|
||||
_ key: TypingKeyHitTarget,
|
||||
on finger: inout Finger,
|
||||
effects: inout TypingTouchEffects
|
||||
) {
|
||||
switch key.behavior {
|
||||
case .commitOnRelease:
|
||||
break
|
||||
case .deleteRepeat:
|
||||
effects.deleteFire = true
|
||||
effects.startDeleteRepeat = true
|
||||
finger.ownsDeleteRepeat = true
|
||||
case .shiftHold:
|
||||
if !finger.ownsShift, !fingers.values.contains(where: { $0.ownsShift }) {
|
||||
effects.beginShift = true
|
||||
finger.ownsShift = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Press order = typing order: flush other uncommitted character keys.
|
||||
private func commitPendingCharacterKeys(into effects: inout TypingTouchEffects) {
|
||||
let pending = fingers.values
|
||||
.filter { !$0.committed && $0.key?.behavior == .commitOnRelease }
|
||||
.sorted { $0.order < $1.order }
|
||||
for finger in pending {
|
||||
if let key = finger.key {
|
||||
effects.commits.append(key)
|
||||
}
|
||||
fingers[finger.id]?.committed = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Holding delete + tapping a letter must stop the repeat.
|
||||
private func stopForeignDeleteRepeats(into effects: inout TypingTouchEffects) {
|
||||
for (id, finger) in fingers where finger.ownsDeleteRepeat {
|
||||
effects.stopDeleteRepeat = true
|
||||
fingers[id]?.ownsDeleteRepeat = false
|
||||
}
|
||||
}
|
||||
|
||||
private func hasUncommittedFinger(on keyID: String) -> Bool {
|
||||
fingers.values.contains { !$0.committed && $0.key?.id == keyID }
|
||||
}
|
||||
|
||||
private func finish(
|
||||
_ finger: Finger,
|
||||
hit: TypingKeyHitTarget?,
|
||||
commitIfNeeded: Bool
|
||||
) -> TypingTouchEffects {
|
||||
var effects = TypingTouchEffects()
|
||||
if finger.ownsDeleteRepeat {
|
||||
effects.stopDeleteRepeat = true
|
||||
}
|
||||
if finger.ownsShift {
|
||||
effects.endShift = true
|
||||
}
|
||||
if commitIfNeeded, !finger.committed, let hit, hit.behavior == .commitOnRelease {
|
||||
effects.commits.append(hit)
|
||||
}
|
||||
return effects
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user