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:
@@ -300,4 +300,81 @@ final class EnglishTypingTests: XCTestCase {
|
||||
XCTAssertEqual(undone.deleteCount, spaced.text.count)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPeriodShortcutReplacesDoubleSpace() {
|
||||
let typing = TypingSessionController()
|
||||
_ = typing.setLanguage(.english)
|
||||
typing.enterTypingMode()
|
||||
|
||||
for character in ["h", "e", "l", "l", "o"] {
|
||||
apply(typing, typing.handleKey(character))
|
||||
}
|
||||
let first = typing.handleSpace()
|
||||
XCTAssertTrue(first.text.hasSuffix(" "), "first Space should insert a space")
|
||||
apply(typing, first)
|
||||
|
||||
let second = typing.handleSpace()
|
||||
XCTAssertEqual(second, .replace(deleteCount: 1, with: ". "))
|
||||
apply(typing, second)
|
||||
XCTAssertTrue(typing.shiftActive, "period+space must arm sentence Shift")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPeriodShortcutDoesNotFireAfterAnotherLetter() {
|
||||
let typing = TypingSessionController()
|
||||
_ = typing.setLanguage(.english)
|
||||
typing.enterTypingMode()
|
||||
|
||||
apply(typing, typing.handleKey("h"))
|
||||
apply(typing, typing.handleSpace())
|
||||
apply(typing, typing.handleKey("i"))
|
||||
let second = typing.handleSpace()
|
||||
XCTAssertEqual(second, .insert(" "))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPeriodShortcutDoesNotFireAfterSentenceTerminator() {
|
||||
let typing = TypingSessionController()
|
||||
_ = typing.setLanguage(.english)
|
||||
typing.precedingTextProvider = { "Hello." }
|
||||
typing.enterTypingMode()
|
||||
typing.syncAutocapitalization()
|
||||
|
||||
apply(typing, typing.handleSpace())
|
||||
let second = typing.handleSpace()
|
||||
XCTAssertEqual(second, .insert(" "))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testChineseSpaceDoesNotUsePeriodShortcut() {
|
||||
let typing = TypingSessionController()
|
||||
_ = typing.setLanguage(.chinese)
|
||||
let first = typing.handleSpace()
|
||||
let second = typing.handleSpace()
|
||||
XCTAssertEqual(first.text, " ")
|
||||
XCTAssertEqual(second.text, " ")
|
||||
XCTAssertEqual(second.deleteCount, 0)
|
||||
}
|
||||
|
||||
func testPeriodShortcutPredicate() {
|
||||
XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello "))
|
||||
XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "你 "))
|
||||
XCTAssertTrue(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "v2 "))
|
||||
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello. "))
|
||||
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello "))
|
||||
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: "hello"))
|
||||
XCTAssertFalse(PeriodShortcut.shouldReplacePreviousSpace(precedingText: " "))
|
||||
XCTAssertTrue(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello"))
|
||||
XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello."))
|
||||
XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello "))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func apply(_ typing: TypingSessionController, _ output: TypingOutput) {
|
||||
typing.syncAutocapitalization(
|
||||
accountingForInsert: output.text,
|
||||
deleteCount: output.deleteCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,4 +143,16 @@ final class KeyboardStateTests: XCTestCase {
|
||||
XCTAssertNil(state.clipboardSuggestionText)
|
||||
XCTAssertEqual(state.clipboardOverlay, .none)
|
||||
}
|
||||
|
||||
func testReturnKeyRoleActionFillAndTitles() {
|
||||
XCTAssertFalse(KeyboardState.ReturnKeyRole.newline.usesActionFill)
|
||||
XCTAssertTrue(KeyboardState.ReturnKeyRole.send.usesActionFill)
|
||||
XCTAssertTrue(KeyboardState.ReturnKeyRole.search.usesActionFill)
|
||||
XCTAssertTrue(KeyboardState.ReturnKeyRole.done.usesActionFill)
|
||||
XCTAssertTrue(KeyboardState.ReturnKeyRole.next.usesActionFill)
|
||||
XCTAssertEqual(KeyboardState.ReturnKeyRole.search.titleKey, "keyboard.return.search")
|
||||
XCTAssertEqual(KeyboardState.ReturnKeyRole.next.titleKey, "keyboard.return.next")
|
||||
XCTAssertEqual(KeyboardState.ReturnKeyRole.done.titleKey, "common.done")
|
||||
XCTAssertEqual(KeyboardState.ReturnKeyRole.go.titleKey, "keyboard.return.go")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// TypingTouchTrackerTests.swift
|
||||
// OSGKeyboard · Ext unit tests
|
||||
//
|
||||
// Multi-finger overlap: press order commits, Shift hold + type, slide reselect.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class TypingTouchTrackerTests: XCTestCase {
|
||||
private final class Token {}
|
||||
|
||||
private func letter(_ id: String) -> TypingKeyHitTarget {
|
||||
TypingKeyHitTarget(
|
||||
id: id,
|
||||
label: id,
|
||||
visualFrame: .zero,
|
||||
behavior: .commitOnRelease
|
||||
)
|
||||
}
|
||||
|
||||
private func shiftKey() -> TypingKeyHitTarget {
|
||||
TypingKeyHitTarget(
|
||||
id: "shift",
|
||||
label: "⇧",
|
||||
visualFrame: .zero,
|
||||
behavior: .shiftHold
|
||||
)
|
||||
}
|
||||
|
||||
private func deleteKey() -> TypingKeyHitTarget {
|
||||
TypingKeyHitTarget(
|
||||
id: "delete",
|
||||
label: "⌫",
|
||||
visualFrame: .zero,
|
||||
behavior: .deleteRepeat
|
||||
)
|
||||
}
|
||||
|
||||
func testSecondFingerDownCommitsFirstInPressOrder() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let a = Token()
|
||||
let b = Token()
|
||||
let keyA = letter("a")
|
||||
let keyB = letter("b")
|
||||
|
||||
let downA = tracker.began(id: ObjectIdentifier(a), key: keyA)
|
||||
XCTAssertTrue(downA.commits.isEmpty)
|
||||
XCTAssertEqual(tracker.highlightedKeyIDs, ["a"])
|
||||
|
||||
let downB = tracker.began(id: ObjectIdentifier(b), key: keyB)
|
||||
XCTAssertEqual(downB.commits.map(\.id), ["a"])
|
||||
XCTAssertEqual(tracker.highlightedKeyIDs, ["b"])
|
||||
|
||||
let upA = tracker.ended(id: ObjectIdentifier(a), key: keyA)
|
||||
XCTAssertTrue(upA.commits.isEmpty)
|
||||
|
||||
let upB = tracker.ended(id: ObjectIdentifier(b), key: keyB)
|
||||
XCTAssertEqual(upB.commits.map(\.id), ["b"])
|
||||
XCTAssertTrue(tracker.highlightedKeyIDs.isEmpty)
|
||||
}
|
||||
|
||||
func testShiftHoldWithOtherFingerDoesNotCommitShift() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let shiftFinger = Token()
|
||||
let letterFinger = Token()
|
||||
let shift = shiftKey()
|
||||
let keyA = letter("a")
|
||||
|
||||
let downShift = tracker.began(id: ObjectIdentifier(shiftFinger), key: shift)
|
||||
XCTAssertTrue(downShift.beginShift)
|
||||
XCTAssertTrue(downShift.commits.isEmpty)
|
||||
|
||||
let downA = tracker.began(id: ObjectIdentifier(letterFinger), key: keyA)
|
||||
XCTAssertTrue(downA.commits.isEmpty)
|
||||
XCTAssertFalse(downA.beginShift)
|
||||
|
||||
let upA = tracker.ended(id: ObjectIdentifier(letterFinger), key: keyA)
|
||||
XCTAssertEqual(upA.commits.map(\.id), ["a"])
|
||||
XCTAssertFalse(upA.endShift)
|
||||
|
||||
let upShift = tracker.ended(id: ObjectIdentifier(shiftFinger), key: shift)
|
||||
XCTAssertTrue(upShift.endShift)
|
||||
XCTAssertTrue(upShift.commits.isEmpty)
|
||||
}
|
||||
|
||||
func testSlideReselectsWithoutCommittingPreviousKey() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let finger = Token()
|
||||
let keyA = letter("a")
|
||||
let keyS = letter("s")
|
||||
|
||||
_ = tracker.began(id: ObjectIdentifier(finger), key: keyA)
|
||||
let moved = tracker.moved(id: ObjectIdentifier(finger), key: keyS)
|
||||
XCTAssertTrue(moved.commits.isEmpty)
|
||||
XCTAssertEqual(moved.playFeedback?.id, "s")
|
||||
XCTAssertEqual(tracker.highlightedKeyIDs, ["s"])
|
||||
|
||||
let ended = tracker.ended(id: ObjectIdentifier(finger), key: keyS)
|
||||
XCTAssertEqual(ended.commits.map(\.id), ["s"])
|
||||
}
|
||||
|
||||
func testLiftOutsideCancelsPendingKey() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let finger = Token()
|
||||
_ = tracker.began(id: ObjectIdentifier(finger), key: letter("a"))
|
||||
let ended = tracker.ended(id: ObjectIdentifier(finger), key: nil)
|
||||
XCTAssertTrue(ended.commits.isEmpty)
|
||||
}
|
||||
|
||||
func testCancelDoesNotCommit() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let finger = Token()
|
||||
_ = tracker.began(id: ObjectIdentifier(finger), key: letter("a"))
|
||||
let cancelled = tracker.cancelled(id: ObjectIdentifier(finger))
|
||||
XCTAssertTrue(cancelled.commits.isEmpty)
|
||||
XCTAssertTrue(tracker.highlightedKeyIDs.isEmpty)
|
||||
}
|
||||
|
||||
func testLetterWhileDeletingStopsRepeat() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let delFinger = Token()
|
||||
let letterFinger = Token()
|
||||
|
||||
let downDelete = tracker.began(id: ObjectIdentifier(delFinger), key: deleteKey())
|
||||
XCTAssertTrue(downDelete.deleteFire)
|
||||
XCTAssertTrue(downDelete.startDeleteRepeat)
|
||||
|
||||
let downLetter = tracker.began(id: ObjectIdentifier(letterFinger), key: letter("a"))
|
||||
XCTAssertTrue(downLetter.stopDeleteRepeat)
|
||||
XCTAssertFalse(downLetter.deleteFire)
|
||||
XCTAssertTrue(downLetter.commits.isEmpty)
|
||||
}
|
||||
|
||||
func testSecondFingerOnSameKeyIsIgnored() {
|
||||
let tracker = TypingTouchTracker()
|
||||
let first = Token()
|
||||
let second = Token()
|
||||
let keyA = letter("a")
|
||||
|
||||
_ = tracker.began(id: ObjectIdentifier(first), key: keyA)
|
||||
let duplicate = tracker.began(id: ObjectIdentifier(second), key: keyA)
|
||||
XCTAssertTrue(duplicate.commits.isEmpty)
|
||||
XCTAssertNil(duplicate.playFeedback)
|
||||
|
||||
let upFirst = tracker.ended(id: ObjectIdentifier(first), key: keyA)
|
||||
XCTAssertEqual(upFirst.commits.map(\.id), ["a"])
|
||||
let upSecond = tracker.ended(id: ObjectIdentifier(second), key: keyA)
|
||||
XCTAssertTrue(upSecond.commits.isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user