Skip to content

Commit 97f7464

Browse files
committed
feat(ContentView): Add tracking of caret position for dictation and text editor
1 parent 902ede9 commit 97f7464

3 files changed

Lines changed: 130 additions & 14 deletions

File tree

Textream/Textream/ContentView.swift

Lines changed: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ struct ContentView: View {
1414
@State private var isRecording = false
1515
@State private var dictation = DictationManager()
1616
@State private var dictationHighlightRange: NSRange? = nil
17+
@State private var dictationCaretPosition: Int? = nil
18+
@State private var editorCaretPosition: Int = 0
1719
@State private var isDroppingPresentation = false
1820
@State private var dropError: String?
1921
@State private var dropAlertTitle: String = "Import Error"
@@ -74,30 +76,105 @@ Happy presenting! [wave]
7476
}
7577
}
7678

77-
private func startRecording() {
78-
// Capture the base text once so partial results replace (not append)
79+
@State private var highlightClearTimer: Timer?
80+
81+
// Segment tracking: each recognition session is a "segment"
82+
@State private var segmentStart: Int = 0
83+
@State private var segmentLength: Int = 0
84+
@State private var segmentNeedsSeparator: Bool = false
85+
// How many chars of the raw recognition result to skip (already committed before cursor move)
86+
@State private var spokenSkipOffset: Int = 0
87+
@State private var lastRawSpokenLength: Int = 0
88+
89+
private func beginNewSegment() {
7990
let pageIndex = service.currentPageIndex
80-
let baseText = service.pages[pageIndex]
81-
let separator = baseText.isEmpty || baseText.hasSuffix(" ") || baseText.hasSuffix("\n") ? "" : " "
91+
guard pageIndex < service.pages.count else { return }
92+
let text = service.pages[pageIndex]
93+
let caret = min(editorCaretPosition, text.count)
94+
95+
// Skip everything already recognized up to this point
96+
spokenSkipOffset = lastRawSpokenLength
97+
98+
// Check if we need a space before the new segment
99+
let charBefore = caret > 0 ? text[text.index(text.startIndex, offsetBy: caret - 1)] : "\n"
100+
segmentNeedsSeparator = !(charBefore == " " || charBefore == "\n" || caret == 0)
101+
segmentStart = caret
102+
segmentLength = 0
103+
}
104+
105+
private func startRecording() {
106+
lastRawSpokenLength = 0
107+
spokenSkipOffset = 0
108+
beginNewSegment()
109+
110+
dictation.onNewSegment = { [self] in
111+
// Recognition restarted — raw counter resets to 0
112+
lastRawSpokenLength = 0
113+
spokenSkipOffset = 0
114+
beginNewSegment()
115+
}
82116

83117
dictation.onTextUpdate = { [self] spokenText in
118+
lastRawSpokenLength = spokenText.count
119+
120+
// Only use the portion after the skip offset
121+
let effectiveText: String
122+
if spokenSkipOffset < spokenText.count {
123+
effectiveText = String(spokenText.suffix(spokenText.count - spokenSkipOffset))
124+
} else {
125+
effectiveText = ""
126+
}
127+
guard !effectiveText.isEmpty else { return }
128+
129+
let pageIndex = service.currentPageIndex
84130
guard pageIndex < service.pages.count else { return }
85-
let newText = baseText + separator + spokenText
86-
service.pages[pageIndex] = newText
87-
// Highlight the newly dictated portion
88-
let start = baseText.count + separator.count
89-
dictationHighlightRange = NSRange(location: start, length: spokenText.count)
131+
var text = service.pages[pageIndex]
132+
133+
// Remove the old segment text
134+
let safeStart = min(segmentStart, text.count)
135+
let removeStart = text.index(text.startIndex, offsetBy: safeStart)
136+
let safeLen = min(segmentLength, text.count - safeStart)
137+
let removeEnd = text.index(removeStart, offsetBy: safeLen)
138+
text.removeSubrange(removeStart..<removeEnd)
139+
140+
// Build the new segment content
141+
let sep = segmentNeedsSeparator ? " " : ""
142+
let newSegment = sep + effectiveText
143+
text.insert(contentsOf: newSegment, at: text.index(text.startIndex, offsetBy: min(segmentStart, text.count)))
144+
145+
let prevLen = segmentLength
146+
segmentLength = newSegment.count
147+
service.pages[pageIndex] = text
148+
149+
// Highlight only the newly added characters
150+
let newChars = segmentLength - prevLen
151+
if newChars > 0 {
152+
let highlightStart = segmentStart + prevLen
153+
dictationHighlightRange = NSRange(location: highlightStart, length: newChars)
154+
}
155+
156+
// Move caret to end of segment
157+
dictationCaretPosition = segmentStart + segmentLength
158+
159+
// Clear highlight after 1s of silence
160+
highlightClearTimer?.invalidate()
161+
highlightClearTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
162+
DispatchQueue.main.async {
163+
dictationHighlightRange = nil
164+
}
165+
}
90166
}
91167
dictation.start()
92168
isRecording = true
93169
}
94170

95171
private func stopRecording() {
96-
// Commit: keep whatever was recognized so far
97-
let lastText = dictation.audioLevels // just to trigger observation
98-
_ = lastText
172+
highlightClearTimer?.invalidate()
173+
highlightClearTimer = nil
174+
dictationHighlightRange = nil
99175
dictation.stop()
100176
dictation.onTextUpdate = nil
177+
dictation.onNewSegment = nil
101178
isRecording = false
102179
}
103180

@@ -107,8 +184,18 @@ Happy presenting! [wave]
107184
HighlightingTextEditor(
108185
text: currentText,
109186
font: .systemFont(ofSize: 16, weight: .regular).rounded,
110-
highlightRange: dictationHighlightRange
187+
highlightRange: dictationHighlightRange,
188+
caretPosition: $dictationCaretPosition,
189+
editorCaretPosition: $editorCaretPosition
111190
)
191+
.onChange(of: editorCaretPosition) { _, newPos in
192+
guard isRecording else { return }
193+
// If caret moved away from end of current segment, user clicked elsewhere
194+
let segmentEnd = segmentStart + segmentLength
195+
if newPos != segmentEnd {
196+
beginNewSegment()
197+
}
198+
}
112199
.padding(.horizontal, 20)
113200
.padding(.top, 8)
114201
.padding(.bottom, 8)

Textream/Textream/DictationManager.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ class DictationManager {
1616
var audioLevels: [CGFloat] = Array(repeating: 0, count: 40)
1717
var error: String?
1818

19-
/// Called on main thread with the latest recognized text for the current session
19+
/// Called on main thread with the latest recognized text for the current segment
2020
var onTextUpdate: ((String) -> Void)?
21+
/// Called on main thread when a new recognition segment begins (after silence/restart)
22+
var onNewSegment: (() -> Void)?
2123

2224
private var speechRecognizer: SFSpeechRecognizer?
2325
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
@@ -172,6 +174,9 @@ class DictationManager {
172174
}
173175
}
174176

177+
// Notify that a new recognition segment is starting
178+
onNewSegment?()
179+
175180
let currentGeneration = sessionGeneration
176181
recognitionTask = speechRecognizer.recognitionTask(with: recognitionRequest) { [weak self] result, error in
177182
guard let self else { return }

Textream/Textream/HighlightingTextEditor.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ struct HighlightingTextEditor: NSViewRepresentable {
2121
var isFocused: FocusState<Bool>.Binding?
2222
/// Range of newly dictated text to highlight with a bump effect
2323
var highlightRange: NSRange? = nil
24+
/// One-shot: set caret to this position, then nilled out
25+
@Binding var caretPosition: Int?
26+
/// Continuously reported current caret position in the editor
27+
@Binding var editorCaretPosition: Int
2428

2529
func makeCoordinator() -> Coordinator {
2630
Coordinator(self)
@@ -75,6 +79,16 @@ struct HighlightingTextEditor: NSViewRepresentable {
7579
if let range = highlightRange, range.location + range.length <= textView.string.count {
7680
context.coordinator.applyBumpHighlight(textView, range: range)
7781
}
82+
83+
// Move caret to requested position (one-shot)
84+
if let pos = caretPosition, pos <= textView.string.count {
85+
let caretRange = NSRange(location: pos, length: 0)
86+
textView.setSelectedRange(caretRange)
87+
textView.scrollRangeToVisible(caretRange)
88+
DispatchQueue.main.async {
89+
self.caretPosition = nil
90+
}
91+
}
7892
}
7993

8094
class Coordinator: NSObject, NSTextViewDelegate {
@@ -96,6 +110,16 @@ struct HighlightingTextEditor: NSViewRepresentable {
96110
applyHighlighting(textView)
97111
}
98112

113+
func textViewDidChangeSelection(_ notification: Notification) {
114+
guard let textView = notification.object as? NSTextView else { return }
115+
let pos = textView.selectedRange().location
116+
if parent.editorCaretPosition != pos {
117+
DispatchQueue.main.async { [weak self] in
118+
self?.parent.editorCaretPosition = pos
119+
}
120+
}
121+
}
122+
99123
private var bumpTimer: Timer?
100124

101125
func applyBumpHighlight(_ textView: NSTextView, range: NSRange) {

0 commit comments

Comments
 (0)