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:
@@ -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