Skip to content

Commit 8c58542

Browse files
committed
Preserve chat soft line breaks on iOS
1 parent a841c27 commit 8c58542

2 files changed

Lines changed: 313 additions & 1 deletion

File tree

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ struct ChatMarkdownRenderer: View {
2121

2222
var body: some View {
2323
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
24+
let renderMarkdown = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: processed.cleaned)
2425
VStack(alignment: .leading, spacing: 10) {
25-
Text(self.markdownText(processed.cleaned))
26+
Text(self.markdownText(renderMarkdown))
2627
.font(self.font)
2728
.foregroundStyle(self.textColor)
2829
.tint(self.linkColor)
@@ -47,6 +48,174 @@ struct ChatMarkdownRenderer: View {
4748
}
4849
}
4950

51+
enum ChatMarkdownDisplayPreprocessor {
52+
static func preserveChatSoftBreaks(in markdown: String) -> String {
53+
let normalized = markdown.replacingOccurrences(of: "\r\n", with: "\n")
54+
let lines = normalized.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
55+
guard lines.count > 1 else { return normalized }
56+
57+
var output = ""
58+
var fence: Fence?
59+
let tableRows = self.tableRowIndices(in: lines)
60+
61+
for index in lines.indices {
62+
let line = lines[index]
63+
let wasInFence = fence != nil
64+
let fenceBoundary = self.fenceBoundary(in: line, activeFence: fence)
65+
if case let .open(nextFence) = fenceBoundary {
66+
fence = nextFence
67+
} else if case .close = fenceBoundary {
68+
fence = nil
69+
}
70+
71+
output += line
72+
73+
guard index < lines.index(before: lines.endIndex) else {
74+
continue
75+
}
76+
77+
let nextLine = lines[lines.index(after: index)]
78+
let nextIndex = lines.index(after: index)
79+
if self.shouldPreserveSoftBreak(
80+
after: line,
81+
before: nextLine,
82+
inTable: tableRows.contains(index) || tableRows.contains(nextIndex),
83+
inFence: wasInFence,
84+
fenceBoundary: fenceBoundary)
85+
{
86+
output += " \n"
87+
} else {
88+
output += "\n"
89+
}
90+
}
91+
92+
return output
93+
}
94+
95+
private enum FenceBoundary {
96+
case none
97+
case open(Fence)
98+
case close
99+
}
100+
101+
private struct Fence {
102+
let character: Character
103+
let count: Int
104+
let hasOnlyTrailingWhitespace: Bool
105+
}
106+
107+
private static func shouldPreserveSoftBreak(
108+
after line: String,
109+
before nextLine: String,
110+
inTable: Bool,
111+
inFence: Bool,
112+
fenceBoundary: FenceBoundary) -> Bool
113+
{
114+
guard !inTable else { return false }
115+
guard !inFence else { return false }
116+
guard case .none = fenceBoundary else { return false }
117+
118+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
119+
let nextTrimmed = nextLine.trimmingCharacters(in: .whitespacesAndNewlines)
120+
guard !trimmed.isEmpty, !nextTrimmed.isEmpty else { return false }
121+
guard !self.hasMarkdownHardBreak(line) else { return false }
122+
guard !self.isBlockMarkdownLine(line), !self.isBlockMarkdownLine(nextLine) else { return false }
123+
return true
124+
}
125+
126+
private static func hasMarkdownHardBreak(_ line: String) -> Bool {
127+
line.hasSuffix("\\") || line.hasSuffix(" ")
128+
}
129+
130+
private static func isBlockMarkdownLine(_ line: String) -> Bool {
131+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
132+
guard !trimmed.isEmpty else { return false }
133+
134+
return self.matches(line, #"^\s{0,3}#{1,6}(\s|$)"#)
135+
|| self.matches(line, #"^\s{0,3}>"#)
136+
|| self.matches(line, #"^\s{0,3}([-+*])\s+"#)
137+
|| self.matches(line, #"^\s{0,3}\d{1,9}[.)]\s+"#)
138+
|| self.matches(line, #"^( {4}|\t)"#)
139+
|| self.matches(line, #"^\s{0,3}((\*\s*){3,}|(-\s*){3,}|(_\s*){3,}|={3,})$"#)
140+
}
141+
142+
private static func tableRowIndices(in lines: [String]) -> Set<Int> {
143+
var indices = Set<Int>()
144+
for index in lines.indices where index > lines.startIndex {
145+
guard self.isTableDelimiterLine(lines[index]), lines[lines.index(before: index)].contains("|") else {
146+
continue
147+
}
148+
149+
indices.insert(lines.index(before: index))
150+
indices.insert(index)
151+
152+
var cursor = lines.index(after: index)
153+
while cursor < lines.endIndex, lines[cursor].contains("|") {
154+
indices.insert(cursor)
155+
cursor = lines.index(after: cursor)
156+
}
157+
}
158+
return indices
159+
}
160+
161+
private static func isTableDelimiterLine(_ line: String) -> Bool {
162+
self.matches(line, #"^\s{0,3}\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$"#)
163+
}
164+
165+
private static func fenceBoundary(in line: String, activeFence: Fence?) -> FenceBoundary {
166+
guard let candidate = self.fenceCandidate(in: line) else {
167+
return .none
168+
}
169+
170+
guard let activeFence else {
171+
return .open(candidate)
172+
}
173+
174+
if candidate.character == activeFence.character,
175+
candidate.count >= activeFence.count,
176+
candidate.hasOnlyTrailingWhitespace
177+
{
178+
return .close
179+
}
180+
return .none
181+
}
182+
183+
private static func fenceCandidate(in line: String) -> Fence? {
184+
var cursor = line.startIndex
185+
var spaces = 0
186+
while cursor < line.endIndex, line[cursor] == " ", spaces < 4 {
187+
spaces += 1
188+
cursor = line.index(after: cursor)
189+
}
190+
guard spaces <= 3, cursor < line.endIndex else {
191+
return nil
192+
}
193+
194+
let character = line[cursor]
195+
guard character == "`" || character == "~" else {
196+
return nil
197+
}
198+
199+
var count = 0
200+
while cursor < line.endIndex, line[cursor] == character {
201+
count += 1
202+
cursor = line.index(after: cursor)
203+
}
204+
guard count >= 3 else {
205+
return nil
206+
}
207+
let trailing = line[cursor...]
208+
return Fence(
209+
character: character,
210+
count: count,
211+
hasOnlyTrailingWhitespace: trailing.allSatisfy(\.isWhitespace))
212+
}
213+
214+
private static func matches(_ line: String, _ pattern: String) -> Bool {
215+
line.range(of: pattern, options: .regularExpression) != nil
216+
}
217+
}
218+
50219
@MainActor
51220
private struct InlineImageList: View {
52221
let images: [ChatMarkdownPreprocessor.InlineImage]
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import Foundation
2+
import Testing
3+
@testable import OpenClawChatUI
4+
5+
struct ChatMarkdownDisplayPreprocessorTests {
6+
@Test func `converts plain chat soft breaks to markdown hard breaks`() throws {
7+
let markdown = """
8+
alpha
9+
beta
10+
gamma
11+
"""
12+
13+
let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
14+
15+
#expect(
16+
prepared == """
17+
alpha
18+
beta
19+
gamma
20+
""")
21+
#expect(try self.renderedCharacters(prepared) == "alpha\nbeta\ngamma")
22+
}
23+
24+
@Test func `keeps blank line paragraph boundaries`() {
25+
let markdown = """
26+
alpha
27+
28+
beta
29+
"""
30+
31+
let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
32+
33+
#expect(prepared == markdown)
34+
}
35+
36+
@Test func `does not duplicate existing hard breaks`() {
37+
let markdown = """
38+
alpha
39+
beta\\
40+
gamma
41+
"""
42+
43+
let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
44+
45+
#expect(prepared == markdown)
46+
}
47+
48+
@Test func `preserves fenced code blocks`() {
49+
let markdown = """
50+
```swift
51+
alpha
52+
beta
53+
```
54+
after
55+
next
56+
"""
57+
58+
let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
59+
60+
#expect(
61+
prepared == """
62+
```swift
63+
alpha
64+
beta
65+
```
66+
after
67+
next
68+
""")
69+
}
70+
71+
@Test func `keeps fence like code content inside active fence`() {
72+
let markdown = """
73+
```text
74+
``` not a close
75+
still code
76+
```
77+
after
78+
next
79+
"""
80+
81+
let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
82+
83+
#expect(
84+
prepared == """
85+
```text
86+
``` not a close
87+
still code
88+
```
89+
after
90+
next
91+
""")
92+
}
93+
94+
@Test func `preserves block markdown structure`() {
95+
let markdown = """
96+
Intro
97+
- item one
98+
- item two
99+
100+
# Heading
101+
> quote
102+
"""
103+
104+
let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
105+
106+
#expect(prepared == markdown)
107+
}
108+
109+
@Test func `preserves table like markdown rows`() {
110+
let markdown = """
111+
A | B
112+
--- | ---
113+
1 | 2
114+
"""
115+
116+
let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
117+
118+
#expect(prepared == markdown)
119+
}
120+
121+
@Test func `converts plain pipe prose soft breaks`() {
122+
let markdown = """
123+
Use foo | bar
124+
then continue
125+
"""
126+
127+
let prepared = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
128+
129+
#expect(
130+
prepared == """
131+
Use foo | bar
132+
then continue
133+
""")
134+
}
135+
136+
private func renderedCharacters(_ markdown: String) throws -> String {
137+
let options = AttributedString.MarkdownParsingOptions(
138+
interpretedSyntax: .full,
139+
failurePolicy: .returnPartiallyParsedIfPossible)
140+
let attributed = try AttributedString(markdown: markdown, options: options)
141+
return String(attributed.characters)
142+
}
143+
}

0 commit comments

Comments
 (0)