Skip to content

Commit 1789640

Browse files
authored
Merge pull request #5 from theMomax/main
Add support for observing and modifying the selected text range.
2 parents 1da0965 + 0883074 commit 1789640

4 files changed

Lines changed: 180 additions & 10 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,32 @@ WindowGroup {
150150
To persist the size, the `fontSize` binding is available.
151151

152152

153+
### Selection and Scrolling
154+
155+
The selected text can be observed and modified via another `Binding`:
156+
157+
```swift
158+
struct ContentView: View {
159+
static private let initialSource = "let a = 42\n"
160+
161+
@State private var source = Self.initialSource
162+
@State private var selection = Self.initialSource.endIndex..<Self.initialSource.endIndex
163+
164+
var body: some View {
165+
CodeEditor(source: $source,
166+
selection: $selection,
167+
language: .swift,
168+
theme: .ocean,
169+
autoscroll: false)
170+
Button("Select All") {
171+
selection = source.startIndex..<source.endIndex
172+
}
173+
}
174+
}
175+
```
176+
When `autoscroll` is `true`, the editor automatically scrolls to the respective
177+
cursor position when `selection` is modfied from the outside, i.e. programatically.
178+
153179
### Highlightr and Shaper
154180

155181
Based on the excellent [Highlightr](https://github.com/raspu/Highlightr).

Sources/CodeEditor/CodeEditor.swift

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,31 @@ import Highlightr
106106
*
107107
* To persist the binding, the `fontSize` binding is available.
108108
*
109+
* ### Selection and Scrolling
110+
*
111+
* The selected text can be observed and modified via another `Binding`:
112+
*
113+
* struct ContentView: View {
114+
* static private let initialSource = "let a = 42\n"
115+
*
116+
* @State private var source = Self.initialSource
117+
* @State private var selection = Self.initialSource.endIndex..<Self.initialSource.endIndex
118+
*
119+
* var body: some View {
120+
* CodeEditor(source: $source,
121+
* selection: $selection,
122+
* language: .swift,
123+
* theme: .ocean,
124+
* autoscroll: false)
125+
* Button("Select All") {
126+
* selection = source.startIndex..<source.endIndex
127+
* }
128+
* }
129+
* }
130+
*
131+
* When `autoscroll` is `true`, the editor automatically scrolls to the respective
132+
* cursor position when `selection` is modfied from the outside, i.e. programatically.
133+
*
109134
* ### Highlightr and Shaper
110135
*
111136
* Based on the excellent [Highlightr](https://github.com/raspu/Highlightr).
@@ -182,6 +207,7 @@ public struct CodeEditor: View {
182207
* - Parameters:
183208
* - source: A binding to a String that holds the source code to be
184209
* edited (or displayed).
210+
* - selection: A binding to the selected range of the `source`.
185211
* - language: Optionally set a language (e.g. `.swift`), otherwise
186212
* Highlight.js will attempt to detect the language.
187213
* - theme: The name of the theme to use, defaults to "pojoaque".
@@ -200,17 +226,22 @@ public struct CodeEditor: View {
200226
* language is used.
201227
* - inset: The editor can be inset in the scroll view. Defaults to
202228
* 8/8.
229+
* - autoscroll: If enabled, the editor automatically scrolls to the respective
230+
* region when the `selection` is changed programatically.
203231
*/
204232
public init(source : Binding<String>,
233+
selection : Binding<Range<String.Index>>? = nil,
205234
language : Language? = nil,
206235
theme : ThemeName = .default,
207236
fontSize : Binding<CGFloat>? = nil,
208237
flags : Flags = .defaultEditorFlags,
209238
indentStyle : IndentStyle = .system,
210239
autoPairs : [ String : String ]? = nil,
211-
inset : CGSize? = nil)
240+
inset : CGSize? = nil,
241+
autoscroll : Bool = true)
212242
{
213243
self.source = source
244+
self.selection = selection
214245
self.fontSize = fontSize
215246
self.language = language
216247
self.themeName = theme
@@ -220,6 +251,7 @@ public struct CodeEditor: View {
220251
self.autoPairs = autoPairs
221252
?? language.flatMap({ CodeEditor.defaultAutoPairs[$0] })
222253
?? [:]
254+
self.autoscroll = autoscroll
223255
}
224256

225257
/**
@@ -268,23 +300,27 @@ public struct CodeEditor: View {
268300
}
269301

270302
private var source : Binding<String>
303+
private var selection : Binding<Range<String.Index>>?
271304
private var fontSize : Binding<CGFloat>?
272305
private let language : Language?
273306
private let themeName : ThemeName
274307
private let flags : Flags
275308
private let indentStyle : IndentStyle
276309
private let autoPairs : [ String : String ]
277310
private let inset : CGSize
311+
private let autoscroll : Bool
278312

279313
public var body: some View {
280314
UXCodeTextViewRepresentable(source : source,
315+
selection : selection,
281316
language : language,
282317
theme : themeName,
283318
fontSize : fontSize,
284319
flags : flags,
285320
indentStyle : indentStyle,
286321
autoPairs : autoPairs,
287-
inset : inset)
322+
inset : inset,
323+
autoscroll : autoscroll)
288324
}
289325
}
290326

Sources/CodeEditor/UXCodeTextView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ protocol UXCodeTextViewDelegate: UXTextViewDelegate {
264264

265265
extension UXTextView {
266266

267-
fileprivate var swiftSelectedRange : Range<String.Index> {
267+
var swiftSelectedRange : Range<String.Index> {
268268
let s = self.string
269269
guard !s.isEmpty else { return s.startIndex..<s.startIndex }
270270
#if os(macOS)

Sources/CodeEditor/UXCodeTextViewRepresentable.swift

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,35 +41,50 @@ struct UXCodeTextViewRepresentable : UXViewRepresentable {
4141
* the opening character. For example: `[ "<": ">" ]` would
4242
* automatically insert the closing ">" if the user enters
4343
* "<".
44+
* - autoscroll: If enabled, the editor automatically scrolls to the respective
45+
* region when the `selection` is changed programatically.
4446
*/
4547
public init(source : Binding<String>,
48+
selection : Binding<Range<String.Index>>?,
4649
language : CodeEditor.Language?,
4750
theme : CodeEditor.ThemeName,
4851
fontSize : Binding<CGFloat>?,
4952
flags : CodeEditor.Flags,
5053
indentStyle : CodeEditor.IndentStyle,
5154
autoPairs : [ String : String ],
52-
inset : CGSize)
55+
inset : CGSize,
56+
autoscroll : Bool)
5357
{
5458
self.source = source
59+
self.selection = selection
5560
self.fontSize = fontSize
5661
self.language = language
5762
self.themeName = theme
5863
self.flags = flags
5964
self.indentStyle = indentStyle
6065
self.autoPairs = autoPairs
6166
self.inset = inset
67+
self.autoscroll = autoscroll
6268
}
6369

6470
private var source : Binding<String>
71+
private var selection : Binding<Range<String.Index>>?
6572
private var fontSize : Binding<CGFloat>?
6673
private let language : CodeEditor.Language?
6774
private let themeName : CodeEditor.ThemeName
6875
private let flags : CodeEditor.Flags
6976
private let indentStyle : CodeEditor.IndentStyle
7077
private let inset : CGSize
7178
private let autoPairs : [ String : String ]
72-
79+
private let autoscroll : Bool
80+
81+
// The inner `value` is true, exactly when execution is inside
82+
// the `updateTextView(_:)` method. The `Coordinator` can use this
83+
// value to guard against update cycles.
84+
// This needs to be a `State`, as the `UXCodeTextViewRepresentable`
85+
// might be destructed and recreated in between calls to `makeCoordinator()`
86+
// and `updateTextView(_:)`.
87+
@State private var isCurrentlyUpdatingView = ReferenceTypeBool(value: false)
7388

7489
// MARK: - TextView Delegate Coordinator
7590

@@ -92,15 +107,66 @@ struct UXCodeTextViewRepresentable : UXViewRepresentable {
92107
assertionFailure("unexpected notification object")
93108
return
94109
}
95-
parent.source.wrappedValue = textView.string
110+
textViewDidChange(textView: textView)
96111
}
97112
#elseif os(iOS)
98113
public func textViewDidChange(_ textView: UITextView) {
99-
parent.source.wrappedValue = textView.string
114+
textViewDidChange(textView: textView)
115+
}
116+
#else
117+
#error("Unsupported OS")
118+
#endif
119+
120+
private func textViewDidChange(textView: UXTextView) {
121+
// This function may be called as a consequence of updating the text string
122+
// in UXCodeTextViewRepresentable/updateTextView(_:)`.
123+
// Since this function might update the `parent.source` `Binding`, which in
124+
// turn might update a `State`, this would lead to undefined behavior.
125+
// (Changing a `State` during a `View` update is not permitted).
126+
guard !parent.isCurrentlyUpdatingView.value else {
127+
return
128+
}
129+
130+
parent.source.wrappedValue = textView.string
131+
}
132+
133+
#if os(macOS)
134+
public func textViewDidChangeSelection(_ notification: Notification) {
135+
guard let textView = notification.object as? UXTextView else {
136+
assertionFailure("unexpected notification object")
137+
return
138+
}
139+
140+
textViewDidChangeSelection(textView: textView as! UXCodeTextView)
141+
}
142+
#elseif os(iOS)
143+
public func textViewDidChangeSelection(_ textView: UITextView) {
144+
textViewDidChangeSelection(textView: textView as! UXCodeTextView)
100145
}
101146
#else
102147
#error("Unsupported OS")
103148
#endif
149+
150+
private func textViewDidChangeSelection(textView: UXCodeTextView) {
151+
// This function may be called as a consequence of updating the selected
152+
// range in UXCodeTextViewRepresentable/updateTextView(_:)`.
153+
// Since this function might update the `parent.selection` `Binding`, which in
154+
// turn might update a `State`, this would lead to undefined behavior.
155+
// (Changing a `State` during a `View` update is not permitted).
156+
guard !parent.isCurrentlyUpdatingView.value else {
157+
return
158+
}
159+
160+
guard let selection = parent.selection else {
161+
return
162+
}
163+
164+
let range = textView.swiftSelectedRange
165+
166+
if selection.wrappedValue != range {
167+
selection.wrappedValue = range
168+
}
169+
}
104170

105171
var allowCopy: Bool {
106172
return parent.flags.contains(.selectable)
@@ -113,6 +179,11 @@ struct UXCodeTextViewRepresentable : UXViewRepresentable {
113179
}
114180

115181
private func updateTextView(_ textView: UXCodeTextView) {
182+
isCurrentlyUpdatingView.value = true
183+
defer {
184+
isCurrentlyUpdatingView.value = false
185+
}
186+
116187
if let binding = fontSize {
117188
textView.applyNewTheme(themeName, andFontSize: binding.wrappedValue)
118189
}
@@ -136,6 +207,25 @@ struct UXCodeTextViewRepresentable : UXViewRepresentable {
136207
}
137208
}
138209

210+
if let selection = selection {
211+
let range = selection.wrappedValue
212+
213+
if range != textView.swiftSelectedRange {
214+
let nsrange = NSRange(range, in: textView.string)
215+
#if os(macOS)
216+
textView.setSelectedRange(nsrange)
217+
#elseif os(iOS)
218+
textView.selectedRange = nsrange
219+
#else
220+
#error("Unsupported OS")
221+
#endif
222+
223+
if autoscroll {
224+
textView.scrollRangeToVisible(nsrange)
225+
}
226+
}
227+
}
228+
139229
textView.isEditable = flags.contains(.editable)
140230
textView.isSelectable = flags.contains(.selectable)
141231
}
@@ -197,28 +287,44 @@ struct UXCodeTextViewRepresentable : UXViewRepresentable {
197287
#endif // iOS
198288
}
199289

290+
extension UXCodeTextViewRepresentable {
291+
// A wrapper around a `Bool` that enables updating
292+
// the wrapped value during `View` renders.
293+
private class ReferenceTypeBool {
294+
var value: Bool
295+
296+
init(value: Bool) {
297+
self.value = value
298+
}
299+
}
300+
}
301+
200302
struct UXCodeTextViewRepresentable_Previews: PreviewProvider {
201303

202304
static var previews: some View {
203305

204306
UXCodeTextViewRepresentable(source : .constant("let a = 5"),
307+
selection : nil,
205308
language : nil,
206309
theme : .pojoaque,
207310
fontSize : nil,
208311
flags : [ .selectable ],
209312
indentStyle : .system,
210313
autoPairs : [:],
211-
inset : .init(width: 8, height: 8))
314+
inset : .init(width: 8, height: 8),
315+
autoscroll : false)
212316
.frame(width: 200, height: 100)
213317

214318
UXCodeTextViewRepresentable(source: .constant("let a = 5"),
319+
selection : nil,
215320
language : .swift,
216321
theme : .pojoaque,
217322
fontSize : nil,
218323
flags : [ .selectable ],
219324
indentStyle : .system,
220325
autoPairs : [:],
221-
inset : .init(width: 8, height: 8))
326+
inset : .init(width: 8, height: 8),
327+
autoscroll : false)
222328
.frame(width: 200, height: 100)
223329

224330
UXCodeTextViewRepresentable(
@@ -228,13 +334,15 @@ struct UXCodeTextViewRepresentable_Previews: PreviewProvider {
228334
\bye
229335
"""#
230336
),
337+
selection : nil,
231338
language : .tex,
232339
theme : .pojoaque,
233340
fontSize : nil,
234341
flags : [ .selectable ],
235342
indentStyle : .system,
236343
autoPairs : [:],
237-
inset : .init(width: 8, height: 8)
344+
inset : .init(width: 8, height: 8),
345+
autoscroll : false
238346
)
239347
.frame(width: 540, height: 200)
240348
}

0 commit comments

Comments
 (0)