eleven / ocho Public

ios+mac: MessageBubble renders the CommonMark subset tree (#531) (#880)

cachedBlocks/richTokens give way to the vector-pinned MarkdownTree walk:
recursive block views (quotes now hold whole blocks; tight lists with
marker columns; fences unchanged) and a recursive inline builder so
marks compose — bold inside italic, marks inside link labels. Parse and
AttributedString memos keep the heavier cmark parse off the scroll path.

Changelog held for the next native release mint.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9c860e265297d2f498aa13b15fd3ae59358d0ac8 parent ad322ec
3 files changed, +158 −86
  • ios/LChatCore/Package.resolved +9 −0
  • ios/LChatCore/Sources/LChatCore/MarkdownTree.swift +2 −2
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +147 −84
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved
index d82f91f..fe25d1c 100644
--- a/ios/LChatCore/Package.resolved
+++ b/ios/LChatCore/Package.resolved
@@ -1,5 +1,14 @@
{
"pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
+ "version" : "2.9.4"
+ }
+ },
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
diff --git a/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift b/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift
index 17a8c8c..bd90dad 100644
--- a/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift
+++ b/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift
@@ -21,7 +21,7 @@ import Markdown
// MARK: - The tree (the vector contract)
-public enum MDInline: Equatable {
+public enum MDInline: Equatable, Hashable {
case text(String)
case br
case code(String)
@@ -31,7 +31,7 @@ public enum MDInline: Equatable {
case strike([MDInline])
}
-public enum MDBlock: Equatable {
+public enum MDBlock: Equatable, Hashable {
case p([MDInline])
case quote([MDBlock])
case ul(items: [[MDBlock]])
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
index 03baaf0..9736265 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
@@ -468,13 +468,12 @@ public struct MessageBubble: View {
.padding(.vertical, 2)
.help(hoverTime)
} else {
- // Block structure first (fences verbatim, quotes ruled), inline
- // formatting within p/quote blocks — format.js's exact layering.
- VStack(alignment: .leading, spacing: 6) {
- ForEach(Array(Self.cachedBlocks(message.text).enumerated()), id: \.offset) { _, block in
- blockView(block)
- }
- }
+ // The allowlisted CommonMark subset (#531): MarkdownTree.swift is
+ // the twin of web/static/markdown.js, so a bubble's block/inline
+ // structure is identical in both feeds (vector-pinned).
+ MDBlockListView(
+ blocks: Self.cachedTree(message.text),
+ ctx: MDContext(fontSize: fontSize, ink: ink, mine: message.mine))
// Selectable AND right-clickable: the MessageRightClick catcher
// above only claims right/ctrl-clicks, so left-drag selection
// reaches the text system while our menu owns the context click.
@@ -490,40 +489,90 @@ public struct MessageBubble: View {
}
}
- @ViewBuilder private func blockView(_ block: BlockToken) -> some View {
- switch block {
- case .p(let text):
- Text(richText(text))
- // The reading size comes from \.chatFontSize (web parity:
- // style.css --msg-font-size) — the Mac's pref or iOS's
- // Dynamic Type body size; derived sizes below keep the fixed
- // ratios they've always had against it.
- .font(.system(size: fontSize))
- .lineSpacing(2)
- .tint(ink) // links inherit the bubble's ink (style.css), underlined
- case .code(let text, _):
- // ``` fence — verbatim monospace on the chip background, long
- // lines scrolling rather than wrapping (.msg .bubble pre).
- ScrollView(.horizontal, showsIndicators: false) {
- Text(text)
- .font(.system(size: fontSize * (13.5 / 16), design: .monospaced))
+ // Everything a nested markdown block needs from its bubble. Kept tiny so
+ // the recursive views below stay value types with cheap bodies.
+ struct MDContext {
+ let fontSize: CGFloat
+ let ink: Color
+ let mine: Bool
+ }
+
+ // Blocks stack exactly as web's .bubble children do (6pt gaps). The two
+ // structs recurse into each other for quotes and list items.
+ struct MDBlockListView: View {
+ let blocks: [MDBlock]
+ let ctx: MDContext
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ ForEach(blocks.indices, id: \.self) { i in
+ MDBlockView(block: blocks[i], ctx: ctx)
+ }
}
- .padding(EdgeInsets(top: 8, leading: 10, bottom: 8, trailing: 10))
- .background(RoundedRectangle(cornerRadius: 8, style: .continuous)
- .fill(ink.opacity(message.mine ? 0.22 : 0.08)))
- case .quote(let text):
- // "> " blockquote — quiet left rule, slightly dimmed text.
- Text(richText(text))
- .font(.system(size: fontSize))
- .lineSpacing(2)
- .tint(ink)
- .opacity(0.75)
- .padding(.leading, 10)
- .overlay(alignment: .leading) {
- RoundedRectangle(cornerRadius: 1.5)
- .fill(ink.opacity(0.35))
- .frame(width: 3)
+ }
+ }
+
+ struct MDBlockView: View {
+ let block: MDBlock
+ let ctx: MDContext
+ var body: some View {
+ switch block {
+ case .p(let children):
+ Text(MessageBubble.inlineText(children, ctx: ctx))
+ // The reading size comes from \.chatFontSize (web parity:
+ // style.css --msg-font-size) — the Mac's pref or iOS's
+ // Dynamic Type body size; derived sizes keep the fixed
+ // ratios they've always had against it.
+ .font(.system(size: ctx.fontSize))
+ .lineSpacing(2)
+ .tint(ctx.ink) // links inherit the bubble's ink, underlined
+ case .codeBlock(let text, _):
+ // ``` fence — verbatim monospace on the chip background, long
+ // lines scrolling rather than wrapping (.msg .bubble pre).
+ ScrollView(.horizontal, showsIndicators: false) {
+ Text(text)
+ .font(.system(size: ctx.fontSize * (13.5 / 16), design: .monospaced))
}
+ .padding(EdgeInsets(top: 8, leading: 10, bottom: 8, trailing: 10))
+ .background(RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(ctx.ink.opacity(ctx.mine ? 0.22 : 0.08)))
+ case .quote(let children):
+ // "> " blockquote — quiet left rule, slightly dimmed content;
+ // quotes now hold whole blocks (lists, fences, nested quotes).
+ MDBlockListView(blocks: children, ctx: ctx)
+ .opacity(0.75)
+ .padding(.leading, 10)
+ .overlay(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(ctx.ink.opacity(0.35))
+ .frame(width: 3)
+ }
+ case .ul(let items):
+ MDListView(marker: { _ in "•" }, items: items, ctx: ctx)
+ case .ol(let start, let items):
+ MDListView(marker: { i in "\(start + i)." }, items: items, ctx: ctx)
+ }
+ }
+ }
+
+ // Tight lists, like the web: marker column + the item's blocks. Ordered
+ // markers render with monospaced digits so multi-digit lists align.
+ struct MDListView: View {
+ let marker: (Int) -> String
+ let items: [[MDBlock]]
+ let ctx: MDContext
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ ForEach(items.indices, id: \.self) { i in
+ HStack(alignment: .firstTextBaseline, spacing: 7) {
+ Text(marker(i))
+ .font(.system(size: ctx.fontSize))
+ .monospacedDigit()
+ .foregroundStyle(ctx.ink.opacity(0.75))
+ MDBlockListView(blocks: items[i], ctx: ctx)
+ }
+ }
+ }
+ .padding(.leading, 4)
}
}
@@ -543,21 +592,27 @@ public struct MessageBubble: View {
// bubble re-evaluates its body on each model publish.
@MainActor private static var hoverTimeMemo: [Int64: String] = [:]
- // A block's text with the web's inline formatting applied — richTokens
- // (RichText.swift) mirrors format.js exactly, so **bold**, _italics_,
- // `code`, ~strikes~ and links render the same in both feeds.
- // Parse results memoised by text: SwiftUI re-evaluates every visible
+ // Inline runs with the web's formatting applied — MarkdownTree.swift is
+ // the twin of web/static/markdown.js, so **bold**, _italics_, `code`,
+ // ~strikes~ and links render the same in both feeds, and marks now nest.
+ // Parse + build results memoised: SwiftUI re-evaluates every visible
// bubble's body on any model publish AND builds fresh rows as they
- // materialise mid-scroll — re-tokenising + rebuilding the AttributedString
- // each time was a per-frame cost that read as periodic scroll hitching.
- @MainActor private static var richMemo: [String: AttributedString] = [:]
- @MainActor private static var blockMemo: [String: [BlockToken]] = [:]
+ // materialise mid-scroll — re-parsing (cmark now, heavier than the old
+ // regex pass) + rebuilding the AttributedString each time was a per-frame
+ // cost that read as periodic scroll hitching.
+ private struct InlineKey: Hashable {
+ let size: CGFloat
+ let mine: Bool
+ let children: [MDInline]
+ }
+ @MainActor private static var richMemo: [InlineKey: AttributedString] = [:]
+ @MainActor private static var treeMemo: [String: [MDBlock]] = [:]
- @MainActor static func cachedBlocks(_ text: String) -> [BlockToken] {
- if let hit = blockMemo[text] { return hit }
- if blockMemo.count > 600 { blockMemo.removeAll() } // crude cap; parses are cheap to redo
- let parsed = blockTokens(text)
- blockMemo[text] = parsed
+ @MainActor static func cachedTree(_ text: String) -> [MDBlock] {
+ if let hit = treeMemo[text] { return hit }
+ if treeMemo.count > 600 { treeMemo.removeAll() } // crude cap; parses are cheap to redo
+ let parsed = markdownTree(text)
+ treeMemo[text] = parsed
return parsed
}
@@ -574,52 +629,60 @@ public struct MessageBubble: View {
return link
}
- private func richText(_ text: String) -> AttributedString {
+ @MainActor static func inlineText(_ children: [MDInline], ctx: MDContext) -> AttributedString {
// The built runs bake in \.chatFontSize, so it's part of the key — a
// ⌘+ / Text Size change must rebuild, not replay the old size.
- let key = "\(fontSize)|\(text)"
- if let hit = Self.richMemo[key] { return hit }
- if Self.richMemo.count > 600 { Self.richMemo.removeAll() }
- let built = buildRichText(text)
- Self.richMemo[key] = built
+ let key = InlineKey(size: ctx.fontSize, mine: ctx.mine, children: children)
+ if let hit = richMemo[key] { return hit }
+ if richMemo.count > 600 { richMemo.removeAll() }
+ let built = buildInline(children, ctx: ctx)
+ richMemo[key] = built
return built
}
- private func buildRichText(_ text: String) -> AttributedString {
+ // Recursive, so nesting composes the way the web's element tree does:
+ // bold inside italic, marks inside link labels, strike over either.
+ private static func buildInline(
+ _ children: [MDInline], ctx: MDContext,
+ bold: Bool = false, italic: Bool = false, strike: Bool = false
+ ) -> AttributedString {
+ func styled(_ s: String) -> AttributedString {
+ var a = AttributedString(s)
+ if bold || italic {
+ var f: Font = .system(size: ctx.fontSize, weight: bold ? .bold : .regular)
+ if italic { f = f.italic() }
+ a.font = f
+ } // plain runs carry no font, inheriting the paragraph Text's
+ if strike { a.strikethroughStyle = .single }
+ return a
+ }
var out = AttributedString()
- for token in richTokens(text) {
- switch token {
+ for node in children {
+ switch node {
case .text(let s):
- out += AttributedString(s)
- case .bold(let s):
- var a = AttributedString(s)
- a.font = .system(size: fontSize, weight: .bold)
- out += a
- case .italic(let s):
- var a = AttributedString(s)
- a.font = .system(size: fontSize).italic()
- out += a
- case .boldItalic(let s):
- var a = AttributedString(s)
- a.font = .system(size: fontSize, weight: .bold).italic()
- out += a
- case .strike(let s):
- var a = AttributedString(s)
- a.strikethroughStyle = .single
- out += a
+ out += styled(s)
+ case .br:
+ out += AttributedString("\n")
case .code(let s):
// Narrow no-break spaces give the chip its side padding — an
// AttributedString background can't pad a run (web: 1px 5px).
var a = AttributedString("\u{202F}\(s)\u{202F}")
- a.font = .system(size: fontSize * 0.92, design: .monospaced) // 0.92em (style.css)
- a.backgroundColor = ink.opacity(message.mine ? 0.22 : 0.08)
+ a.font = .system(size: ctx.fontSize * 0.92, design: .monospaced) // 0.92em (style.css)
+ a.backgroundColor = ctx.ink.opacity(ctx.mine ? 0.22 : 0.08)
+ if strike { a.strikethroughStyle = .single }
out += a
- case .link(let s, let url):
- var a = AttributedString(s)
- a.link = url
+ case .link(let href, let linkChildren):
+ var a = buildInline(linkChildren, ctx: ctx, bold: bold, italic: italic, strike: strike)
+ if let url = URL(string: href) { a.link = url }
a.underlineStyle = .single
- a.foregroundColor = ink
+ a.foregroundColor = ctx.ink
out += a
+ case .strong(let inner):
+ out += buildInline(inner, ctx: ctx, bold: true, italic: italic, strike: strike)
+ case .em(let inner):
+ out += buildInline(inner, ctx: ctx, bold: bold, italic: true, strike: strike)
+ case .strike(let inner):
+ out += buildInline(inner, ctx: ctx, bold: bold, italic: italic, strike: true)
}
}
return out