Skip to content

Commit 96a10ad

Browse files
committed
Add attributed string postprocessor
The MarkupParser only runs when the input string changes, so there is no way to add attributes whose source is external to the content. This adds a postprocessing stage that runs after parsing and is re-invoked whenever either the content or the postprocessor itself changes, enabling content-independent customization like search highlighting in both InlineText and StructuredText.
1 parent 01b5187 commit 96a10ad

10 files changed

Lines changed: 296 additions & 9 deletions

File tree

Examples/TextualDemo/TextualDemo/DemoSplitView.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ enum DemoItem: String, CaseIterable, Hashable {
1010
case attachmentLoaders
1111
case tables
1212
case mathExpressions
13+
case postprocessor
1314
case gitHubReadme
1415
}
1516

@@ -40,6 +41,8 @@ extension DemoItem {
4041
return Label("Tables", systemImage: "tablecells")
4142
case .mathExpressions:
4243
return Label("Math Expressions", systemImage: "x.squareroot")
44+
case .postprocessor:
45+
return Label("Postprocessor", systemImage: "highlighter")
4346
case .gitHubReadme:
4447
return Label("GitHub `README`", systemImage: "doc.text")
4548
}
@@ -75,6 +78,9 @@ extension DemoItem {
7578
case .mathExpressions:
7679
MathExpressionDemo()
7780
.navigationTitle("Math Expressions")
81+
case .postprocessor:
82+
PostprocessorDemo()
83+
.navigationTitle("Postprocessor")
7884
case .gitHubReadme:
7985
GitHubReadmeDemo()
8086
.navigationTitle("GitHub README")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import SwiftUI
2+
import Textual
3+
4+
struct PostprocessorDemo: View {
5+
@State private var query = "text"
6+
7+
var body: some View {
8+
Form {
9+
Section("Search Highlighter") {
10+
TextField("Search", text: $query)
11+
}
12+
13+
Section("Inline Text") {
14+
InlineText(
15+
markdown: """
16+
Textual renders **rich attributed text** in SwiftUI. Use a postprocessor \
17+
to highlight ranges independently of the markup content.
18+
"""
19+
)
20+
.textual.attributedStringPostprocessor(SearchHighlighter(query: query))
21+
.textual.textSelection(.enabled)
22+
}
23+
24+
Section("Structured Text") {
25+
StructuredText(
26+
markdown: """
27+
## Overview
28+
29+
A postprocessor runs **after parsing and attachment resolution**, so it can
30+
add attributes whose source is external to the text — for example, a
31+
live search query.
32+
33+
- Highlights update without re-parsing
34+
- Ranges are valid against the full attributed string
35+
"""
36+
)
37+
.textual.attributedStringPostprocessor(SearchHighlighter(query: query))
38+
.textual.textSelection(.enabled)
39+
}
40+
}
41+
.formStyle(.grouped)
42+
}
43+
}
44+
45+
private struct SearchHighlighter: AttributedStringPostprocessor {
46+
let query: String
47+
48+
func postprocess(input: AttributedString) -> AttributedString {
49+
guard !query.isEmpty else {
50+
return input
51+
}
52+
53+
var output = input
54+
var start = output.startIndex
55+
56+
while let range = output[start...].range(of: query, options: .caseInsensitive) {
57+
output[range].backgroundColor = .yellow.opacity(0.4)
58+
start = range.upperBound
59+
}
60+
61+
return output
62+
}
63+
}
64+
65+
#Preview {
66+
PostprocessorDemo()
67+
}

README.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,11 @@ Here's a practical example, a custom heading style that adds a subtle underline
202202
```swift
203203
struct CustomHeadingStyle: StructuredText.HeadingStyle {
204204
private static let fontScales: [CGFloat] = [2, 1.5, 1.25, 1, 0.875, 0.85]
205-
205+
206206
func makeBody(configuration: Configuration) -> some View {
207207
let headingLevel = min(configuration.headingLevel, 6)
208208
let fontScale = Self.fontScales[headingLevel - 1]
209-
209+
210210
VStack(alignment: .leading, spacing: 0) {
211211
configuration.label
212212
.textual.fontScale(fontScale)
@@ -287,6 +287,41 @@ StructuredText(markdown: content)
287287
The protocol requires implementations for all block types, list markers, and inline styles. This ensures visual
288288
consistency across your entire document.
289289

290+
### Postprocessing Attributed Content
291+
292+
The `MarkupParser` only runs when the input string changes, so attributes whose source is external
293+
to the content (a search query, a live filter) belong in a postprocessor. A
294+
postprocessor transforms the parsed `AttributedString` after attachments are resolved and is
295+
re-invoked whenever either the parsed string or the postprocessor itself changes.
296+
297+
Apply a postprocessor using the `textual.attributedStringPostprocessor(_:)` modifier:
298+
299+
```swift
300+
struct SearchHighlighter: AttributedStringPostprocessor {
301+
let query: String
302+
303+
func postprocess(input: AttributedString) -> AttributedString {
304+
var output = input
305+
var start = output.startIndex
306+
307+
while let range = output[start...].range(of: query, options: .caseInsensitive) {
308+
output[range].backgroundColor = .yellow.opacity(0.4)
309+
start = range.upperBound
310+
}
311+
312+
return output
313+
}
314+
}
315+
316+
InlineText(markdown: "Hello, world")
317+
.textual.attributedStringPostprocessor(SearchHighlighter(query: "world"))
318+
```
319+
320+
The postprocessor receives the full attributed string with attachments already resolved; ranges
321+
are valid against that complete string. Equality (from `Hashable`) drives invalidation, so the
322+
conforming type must reflect any state that affects output in its `==` implementation — typically
323+
by storing that state as stored properties of a value type.
324+
290325
## Demos
291326

292327
This repository includes a demo app that showcases all of Textual's features, from inline formatting and custom emoji

Sources/Textual/InlineText/InlineText.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@ public struct InlineText: View {
112112

113113
public var body: some View {
114114
WithAttachments(attributedString) {
115-
WithInlineStyle($0) {
116-
TextFragment($0)
117-
.modifier(TextSelectionInteraction())
115+
WithAttributedStringPostprocessor($0) {
116+
WithInlineStyle($0) {
117+
TextFragment($0)
118+
.modifier(TextSelectionInteraction())
119+
}
118120
}
119121
}
120122
.coordinateSpace(.textContainer)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import SwiftUI
2+
3+
// MARK: - Overview
4+
//
5+
// `AnyAttributedStringPostprocessor` is a type-erased wrapper around any
6+
// `AttributedStringPostprocessor`, used to store the postprocessor in `EnvironmentValues` while
7+
// preserving `Equatable` semantics for `onChange`-driven invalidation.
8+
9+
struct AnyAttributedStringPostprocessor: AttributedStringPostprocessor {
10+
private let base: any AttributedStringPostprocessor
11+
12+
init(_ base: some AttributedStringPostprocessor) {
13+
self.base = base
14+
}
15+
16+
func postprocess(input: AttributedString) -> AttributedString {
17+
base.postprocess(input: input)
18+
}
19+
20+
static func == (lhs: Self, rhs: Self) -> Bool {
21+
AnyHashable(lhs.base) == AnyHashable(rhs.base)
22+
}
23+
24+
func hash(into hasher: inout Hasher) {
25+
hasher.combine(base)
26+
}
27+
}
28+
29+
extension EnvironmentValues {
30+
@Entry var attributedStringPostprocessor: AnyAttributedStringPostprocessor = .init(
31+
IdentityAttributedStringPostprocessor()
32+
)
33+
}
34+
35+
fileprivate struct IdentityAttributedStringPostprocessor: AttributedStringPostprocessor {
36+
func postprocess(input: AttributedString) -> AttributedString {
37+
input
38+
}
39+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import SwiftUI
2+
3+
// MARK: - Overview
4+
//
5+
// `WithAttributedStringPostprocessor` applies an `AttributedStringPostprocessor` from the
6+
// environment to an `AttributedString` before it reaches the rendering pipeline.
7+
//
8+
// The view reads `attributedStringPostprocessor` from the environment, then produces a processed
9+
// copy of the attributed string by calling `postprocess(input:)`.
10+
//
11+
// Processing is recomputed whenever the input or the postprocessor changes.
12+
13+
struct WithAttributedStringPostprocessor<Content: View>: View {
14+
@Environment(\.attributedStringPostprocessor) private var attributedStringPostprocessor
15+
16+
@State private var output: AttributedString?
17+
18+
let attributedString: AttributedString
19+
let content: (AttributedString) -> Content
20+
21+
var body: some View {
22+
content(output ?? AttributedString())
23+
.onChange(of: Tuple(attributedString, attributedStringPostprocessor), initial: true) {
24+
output = attributedStringPostprocessor.postprocess(input: attributedString)
25+
}
26+
}
27+
28+
init(
29+
_ attributedString: AttributedString,
30+
@ViewBuilder content: @escaping (AttributedString) -> Content
31+
) {
32+
self.attributedString = attributedString
33+
self.content = content
34+
}
35+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import SwiftUI
2+
3+
/// Transforms an attributed string after parsing and before rendering.
4+
///
5+
/// `InlineText` and `StructuredText` parse markup into an `AttributedString` using a
6+
/// ``MarkupParser``, resolve attachments, and then apply a postprocessor, giving you
7+
/// a chance to add or modify attributes that are independent of the markup
8+
/// itself — for example, highlighting search matches or annotating ranges.
9+
///
10+
/// This stage is separate from the ``MarkupParser`` because the data added by a postprocessor may
11+
/// change independently of the markup content. A parser only runs when the input string changes,
12+
/// so attributes whose source is external (a search query, a live filter) belong in a
13+
/// postprocessor rather than in the parser. The postprocessor is re-invoked whenever either the
14+
/// parsed attributed string or the postprocessor itself changes.
15+
///
16+
/// Apply a postprocessor using the ``TextualNamespace/attributedStringPostprocessor(_:)`` modifier:
17+
///
18+
/// ```swift
19+
/// InlineText(markdown: "Hello, world")
20+
/// .textual.attributedStringPostprocessor(SearchHighlighter(query: "world"))
21+
/// ```
22+
///
23+
/// The postprocessor receives the full attributed string, including resolved attachments.
24+
/// Ranges are valid against that complete string. Avoid dropping runs that carry
25+
/// attachment attributes, as this desyncs the rendered attachments from the attributed content.
26+
///
27+
/// ### When the Postprocessor Runs
28+
///
29+
/// The postprocessor is invoked whenever the parsed attributed string or the postprocessor itself
30+
/// changes. Equality is used to detect changes in the postprocessor, so the
31+
/// conforming type must reflect any state that affects output in its `==` implementation.
32+
33+
public protocol AttributedStringPostprocessor: Hashable {
34+
/// Returns a transformed attributed string for rendering.
35+
///
36+
/// - Parameter input: The parsed, attachment-resolved attributed string.
37+
/// - Returns: The attributed string to render.
38+
func postprocess(input: AttributedString) -> AttributedString
39+
}

Sources/Textual/StructuredText/StructuredText.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,11 @@ public struct StructuredText: View {
117117

118118
public var body: some View {
119119
WithAttachments(attributedString) {
120-
BlockContent(content: $0)
121-
.modifier(TextSelectionInteraction())
122-
.modifier(TextSelectionCoordination())
120+
WithAttributedStringPostprocessor($0) {
121+
BlockContent(content: $0)
122+
.modifier(TextSelectionInteraction())
123+
.modifier(TextSelectionCoordination())
124+
}
123125
}
124126
.coordinateSpace(.textContainer)
125127
.onChange(of: markup, initial: true) {

Sources/Textual/View+Textual.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,14 @@ extension TextualNamespace where Base: View {
146146
public func emojiAttachmentLoader(_ loader: some AttachmentLoader) -> some View {
147147
base.environment(\.emojiAttachmentLoader, loader)
148148
}
149-
149+
150+
/// Sets the postprocessor used to transform attributed content before rendering.
151+
public func attributedStringPostprocessor(
152+
_ postprocessor: some AttributedStringPostprocessor
153+
) -> some View {
154+
base.environment(\.attributedStringPostprocessor, .init(postprocessor))
155+
}
156+
150157
/// Enables or disables text selection for ``InlineText`` and ``StructuredText``.
151158
@available(tvOS, unavailable)
152159
@available(watchOS, unavailable)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import SwiftUI
2+
import Testing
3+
4+
@testable import Textual
5+
6+
struct AttributedStringPostprocessorTests {
7+
@Test func typeErasedBoxEquality() {
8+
let a = AnyAttributedStringPostprocessor(Fixtures.SearchHighlighter(query: "world"))
9+
let b = AnyAttributedStringPostprocessor(Fixtures.SearchHighlighter(query: "world"))
10+
let c = AnyAttributedStringPostprocessor(Fixtures.SearchHighlighter(query: "Hello"))
11+
12+
#expect(a == b)
13+
#expect(a != c)
14+
}
15+
16+
@Test func typeErasedBoxHashability() {
17+
let a = AnyAttributedStringPostprocessor(Fixtures.SearchHighlighter(query: "world"))
18+
let b = AnyAttributedStringPostprocessor(Fixtures.SearchHighlighter(query: "world"))
19+
let c = AnyAttributedStringPostprocessor(Fixtures.SearchHighlighter(query: "Hello"))
20+
21+
#expect(a.hashValue == b.hashValue)
22+
#expect(a.hashValue != c.hashValue)
23+
}
24+
25+
@Test func typeErasedBoxDelegatesToBase() {
26+
let box = AnyAttributedStringPostprocessor(Fixtures.SearchHighlighter(query: "world"))
27+
let input = AttributedString("Hello, world")
28+
let output = box.postprocess(input: input)
29+
30+
let highlightedRanges = output.runs.compactMap { run -> Range<AttributedString.Index>? in
31+
run.backgroundColor != nil ? run.range : nil
32+
}
33+
34+
#expect(highlightedRanges.count == 1)
35+
#expect(String(input[highlightedRanges[0]].characters) == "world")
36+
}
37+
}
38+
39+
private enum Fixtures {
40+
struct SearchHighlighter: AttributedStringPostprocessor {
41+
let query: String
42+
43+
func postprocess(input: AttributedString) -> AttributedString {
44+
var output = input
45+
var start = output.startIndex
46+
47+
while let range = output[start...].range(of: query, options: .caseInsensitive) {
48+
output[range].backgroundColor = .yellow
49+
start = range.upperBound
50+
}
51+
52+
return output
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)