diff --git a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Phase.swift b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Phase.swift index 5a6ccd51..68cec9cb 100644 --- a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Phase.swift +++ b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Phase.swift @@ -87,7 +87,9 @@ extension KeyboardModel { SharedStore.remove(forKey: BlurtShared.Key.result) // Joined against the live text before the cursor, not the press-time // snapshot: the user may have typed since. - proxy.insertText(InsertionSeparator.withLeadingSeparator(result.text, after: proxy.documentContextBeforeInput)) + let inserted = InsertionSeparator.withLeadingSeparator(result.text, after: proxy.documentContextBeforeInput) + proxy.insertText(inserted) + lastInsertion = inserted resultLandedAt = Date() // Not typing, so not the term field's to claim. if termDraft != nil { termHostBaseline = proxy.documentContextBeforeInput ?? "" } diff --git a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift index 90bf6b9a..f84bbbea 100644 --- a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift +++ b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift @@ -21,9 +21,14 @@ extension KeyboardModel { /// highlighted: the picture follows at once, not on the host's next /// notification. private func typedIntoHost() { + lastInsertion = nil readSelection() } + /// Delete right after a dictation takes back all of it — but only while the + /// caret still sits at its end with nothing highlighted. A moved caret, a + /// selection, or a host that shows less context than the dictation's length + /// falls back to deleting one character. package func deleteBackward() { UIDevice.current.playInputClick() if termDraft != nil { @@ -31,7 +36,13 @@ extension KeyboardModel { updateShift() return } - proxy?.deleteBackward() + if let inserted = lastInsertion, let proxy, (proxy.selectedText ?? "").isEmpty, + proxy.documentContextBeforeInput?.hasSuffix(inserted) == true + { + for _ in inserted { proxy.deleteBackward() } + } else { + proxy?.deleteBackward() + } typedIntoHost() } diff --git a/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift b/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift index db10ce0d..75c42b40 100644 --- a/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift +++ b/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift @@ -67,6 +67,9 @@ package final class KeyboardModel { @ObservationIgnored package var gate = DictationKeyGate() @ObservationIgnored package let clockStart = ContinuousClock.now @ObservationIgnored package var lastResultID: UUID? + /// What the last dictation put in the field, separator included, so one + /// delete takes the whole of it back. Any keystroke into the host clears it. + @ObservationIgnored package var lastInsertion: String? @ObservationIgnored private var heartbeat: Task? @ObservationIgnored package var lastSpaceAt: ContinuousClock.Instant? @ObservationIgnored package var noticeDwell: Task? diff --git a/App/BlurtiOS/BlurtiOSTests/ProtocolTests.swift b/App/BlurtiOS/BlurtiOSTests/ProtocolTests.swift index 41ca2eea..7570029b 100644 --- a/App/BlurtiOS/BlurtiOSTests/ProtocolTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/ProtocolTests.swift @@ -210,6 +210,45 @@ struct KeyboardModelProtocolTests { #expect(proxy.before == "Hi one four") } + @Test("delete right after a dictation takes all of it back; otherwise one character") + func deleteTakesBackDictation() { + let suite = ScratchSuite() + defer { suite.tearDown() } + let rig = KeyboardRig() + let model = rig.model + let proxy = rig.proxy + func deliver(_ text: String) { + SharedStore.write( + DictationResult(id: UUID(), text: text, deliveredAt: Date(), recipient: nil), + forKey: BlurtShared.Key.result) + model.resultArrived() + } + proxy.before = "Hi" + deliver("there friend") + #expect(proxy.before == "Hi there friend") + model.deleteBackward() + #expect(proxy.before == "Hi") + // Only once: the next delete is a character again. + model.deleteBackward() + #expect(proxy.before == "H") + + // Typing after the dictation makes delete a character again. + deliver("again") + model.type("!") + model.deleteBackward() + #expect(proxy.before == "H again") + + // So does a caret that no longer sits at the dictation's end, or a selection. + deliver("moved") + proxy.before += "x" + model.deleteBackward() + #expect(proxy.before == "H again moved") + deliver("chosen") + proxy.selected = "chosen" + model.deleteBackward() + #expect(proxy.before == "H again moved chose") + } + @Test("the term field never claims a cursor move or a selection") func termFieldIgnoresCursorMoves() { let suite = ScratchSuite()