Skip to content

Commit 620ad26

Browse files
committed
Preserve caller attributes on code block content
`HighlightedTextFragment` rebuilt every syntax token from its plain `String`, which discarded all attributes the caller's `MarkupParser` had applied to the code block. Any decoration a client added — search highlighting, custom inline styling — was silently dropped between parsing and rendering, with no seam to intervene: `CodeBlockStyle` only receives a type-erased label, so a custom style cannot recover the text either. Walk the original attributed content in parallel with the tokens and slice it instead. Theme attributes are still merged on top, so syntax colouring wins for the keys it sets while caller attributes survive for the rest. If the tokenizer's total length ever disagrees with the source, fall back to the previous plain-token behaviour so text is never lost. Found while adding find-in-text search highlighting to an app that renders notes with Textual: matches inside fenced code blocks were located and highlighted correctly by the parser, then vanished before reaching the screen.
1 parent 01b5187 commit 620ad26

2 files changed

Lines changed: 157 additions & 1 deletion

File tree

Sources/Textual/Internal/Highlighter/HighlightedTextFragment.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ struct HighlightedTextFragment: View {
4040
.onChange(of: Tuple(model.tokens, textEnvironment)) { _, newValue in
4141
model.highlight(
4242
tokens: newValue.values.0,
43+
original: content,
4344
presentationIntent: content.presentationIntent,
4445
using: theme,
4546
environment: newValue.values.1
@@ -64,6 +65,7 @@ extension HighlightedTextFragment {
6465

6566
func highlight(
6667
tokens: [CodeToken],
68+
original: AttributedSubstring,
6769
presentationIntent: PresentationIntent?,
6870
using theme: StructuredText.HighlighterTheme,
6971
environment: TextEnvironmentValues
@@ -75,14 +77,35 @@ extension HighlightedTextFragment {
7577
.apply(in: &attributes, environment: environment)
7678
var highlightedCode = AttributedString()
7779

80+
// Walk the ORIGINAL attributed content in parallel with the tokens and
81+
// slice it, rather than rebuilding each token from its plain String.
82+
// Rebuilding discards every attribute the caller's `MarkupParser` applied
83+
// before it can reach the screen. Theme attributes are merged on top, so
84+
// syntax colouring still wins for the keys it sets while caller attributes
85+
// survive for the rest.
86+
let total = original.characters.count
87+
var consumed = 0
88+
7889
for token in tokens {
79-
var content = AttributedString(token.content)
8090
var tokenAttributes = attributes
8191

8292
if let tokenProperties = theme.tokenProperties[token.type] {
8393
tokenProperties.apply(in: &tokenAttributes, environment: environment)
8494
}
8595

96+
let length = token.content.count
97+
var content: AttributedString
98+
if consumed + length <= total {
99+
let lower = original.index(original.startIndex, offsetByCharacters: consumed)
100+
let upper = original.index(original.startIndex, offsetByCharacters: consumed + length)
101+
content = AttributedString(original[lower..<upper])
102+
} else {
103+
// Tokenizer disagreed with the source length; fall back to the plain
104+
// token so text is never lost, accepting the attribute loss.
105+
content = AttributedString(token.content)
106+
}
107+
consumed += length
108+
86109
content.mergeAttributes(tokenAttributes)
87110
highlightedCode.append(content)
88111
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import SwiftUI
2+
import Testing
3+
4+
@testable import Textual
5+
6+
@MainActor
7+
struct HighlightedTextFragmentTests {
8+
private let environment = TextEnvironmentValues()
9+
10+
/// Builds the `AttributedSubstring` a code block receives, with `background`
11+
/// applied over `highlightedRange` the way a client's `MarkupParser` would.
12+
private func content(
13+
_ text: String,
14+
background: Color? = nil,
15+
over highlightedRange: Range<Int>? = nil
16+
) -> AttributedSubstring {
17+
var attributed = AttributedString(text)
18+
if let background, let highlightedRange {
19+
let lower = attributed.index(
20+
attributed.startIndex, offsetByCharacters: highlightedRange.lowerBound)
21+
let upper = attributed.index(
22+
attributed.startIndex, offsetByCharacters: highlightedRange.upperBound)
23+
attributed[lower..<upper].backgroundColor = background
24+
}
25+
return attributed[attributed.startIndex..<attributed.endIndex]
26+
}
27+
28+
private func highlighted(
29+
_ text: String,
30+
tokens: [CodeToken],
31+
background: Color? = nil,
32+
over highlightedRange: Range<Int>? = nil,
33+
theme: StructuredText.HighlighterTheme = .default
34+
) -> AttributedString {
35+
let model = HighlightedTextFragment.Model()
36+
model.highlight(
37+
tokens: tokens,
38+
original: content(text, background: background, over: highlightedRange),
39+
presentationIntent: nil,
40+
using: theme,
41+
environment: environment
42+
)
43+
return model.highlightedCode ?? AttributedString()
44+
}
45+
46+
@Test func preservesCallerAttributesOnCodeBlockContent() {
47+
let result = highlighted(
48+
"let secret = 1",
49+
tokens: [CodeToken(content: "let secret = 1", type: .plain)],
50+
background: .yellow,
51+
over: 4..<10
52+
)
53+
54+
let marked = result.runs
55+
.filter { $0.backgroundColor == .yellow }
56+
.map { String(result[$0.range].characters) }
57+
58+
#expect(marked == ["secret"])
59+
}
60+
61+
/// The attribute has to survive being split across token boundaries too — the
62+
/// tokenizer knows nothing about the caller's ranges.
63+
@Test func preservesCallerAttributesAcrossTokenBoundaries() {
64+
let result = highlighted(
65+
"let secret = 1",
66+
tokens: [
67+
CodeToken(content: "let ", type: .keyword),
68+
CodeToken(content: "secret", type: .plain),
69+
CodeToken(content: " = 1", type: .plain),
70+
],
71+
background: .yellow,
72+
over: 4..<10
73+
)
74+
75+
let marked = result.runs
76+
.filter { $0.backgroundColor == .yellow }
77+
.map { String(result[$0.range].characters) }
78+
79+
#expect(marked == ["secret"])
80+
}
81+
82+
@Test func textIsUnchangedWhenTheCallerAppliesNoAttributes() {
83+
let result = highlighted(
84+
"let secret = 1",
85+
tokens: [CodeToken(content: "let secret = 1", type: .plain)]
86+
)
87+
88+
#expect(String(result.characters) == "let secret = 1")
89+
#expect(result.runs.allSatisfy { $0.backgroundColor == nil })
90+
}
91+
92+
/// Theme token properties are merged on top, so syntax colouring still wins
93+
/// for the keys it sets while caller attributes survive for the rest.
94+
@Test func themeStillStylesTokens() {
95+
let theme = StructuredText.HighlighterTheme(
96+
foregroundColor: .init(light: .black, dark: .white),
97+
backgroundColor: .init(light: .white, dark: .black),
98+
tokenProperties: [
99+
.keyword: AnyTextProperty(ForegroundColorProperty(.init(light: .red, dark: .red)))
100+
]
101+
)
102+
103+
let result = highlighted(
104+
"let secret = 1",
105+
tokens: [
106+
CodeToken(content: "let ", type: .keyword),
107+
CodeToken(content: "secret = 1", type: .plain),
108+
],
109+
background: .yellow,
110+
over: 4..<10,
111+
theme: theme
112+
)
113+
114+
// The caller's background survived...
115+
#expect(result.runs.contains { $0.backgroundColor == .yellow })
116+
// ...and the theme still coloured the keyword.
117+
#expect(result.runs.contains { $0.foregroundColor == .red })
118+
}
119+
120+
/// If the tokenizer's total length disagrees with the source, the extra token
121+
/// falls back to its plain string so text is never lost.
122+
@Test func fallsBackToPlainTokenWhenLengthsDisagree() {
123+
let result = highlighted(
124+
"abc",
125+
tokens: [
126+
CodeToken(content: "abc", type: .plain),
127+
CodeToken(content: "extra", type: .plain),
128+
]
129+
)
130+
131+
#expect(String(result.characters) == "abcextra")
132+
}
133+
}

0 commit comments

Comments
 (0)