| 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 |