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:
Rocky
2026-08-14 18:22:41 +08:00
parent 0a60006d2d
commit 320dc777c9
46 changed files with 1429 additions and 376 deletions
@@ -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
}
}