import SwiftUI enum MDSegment { case text(AttributedString) case image(ImageSource, src: String) } enum AppLink { static func task(_ id: String, tab: String? = nil) -> URL { route("#/task/\(id)\(tab.map { "/\($0)" } ?? "")") } static func route(_ hash: String) -> URL { var c = URLComponents() c.scheme = "byre-app" c.host = "route" c.queryItems = [URLQueryItem(name: "hash", value: hash)] return c.url! } static func file(_ taskId: String, _ path: String) -> URL { var c = URLComponents() c.scheme = "byre-app" c.host = "file" c.queryItems = [URLQueryItem(name: "task", value: taskId), URLQueryItem(name: "path", value: path)] return c.url! } static func todo(_ projectId: String, _ title: String, discuss: Bool) -> URL { var c = URLComponents() c.scheme = "byre-app" c.host = "todo" c.queryItems = [URLQueryItem(name: "project", value: projectId), URLQueryItem(name: "title", value: title), URLQueryItem(name: "discuss", value: discuss ? "1" : "0")] return c.url! } static func suggest(_ projectId: String, _ title: String, _ brief: String, discuss: Bool) -> URL { var c = URLComponents() c.scheme = "byre-app" c.host = "suggest" c.queryItems = [URLQueryItem(name: "project", value: projectId), URLQueryItem(name: "title", value: title), URLQueryItem(name: "brief", value: brief), URLQueryItem(name: "discuss", value: discuss ? "1" : "0")] return c.url! } } @MainActor func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAction.Result { guard url.scheme == "byre-app", let c = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return .systemAction } let q = Dictionary((c.queryItems ?? []).map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { a, _ in a }) switch c.host { case "route": store.route(q["hash"] ?? "") case "file": store.openFile(q["task"] ?? "", q["path"] ?? "") case "todo": store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays) case "suggest": if q["discuss"] == "1" { store.discussSuggestion(q["project"] ?? "", q["title"] ?? "", brief: q["brief"] ?? "", overlays) } else { store.startSuggestion(q["project"] ?? "", q["title"] ?? "", brief: q["brief"] ?? "", overlays) } default: break } return .handled } private struct ChoiceTapKey: EnvironmentKey { static let defaultValue: (@MainActor @Sendable () -> Void)? = nil } private struct CenterImagesKey: EnvironmentKey { static let defaultValue = false } extension EnvironmentValues { var choiceTap: (@MainActor @Sendable () -> Void)? { get { self[ChoiceTapKey.self] } set { self[ChoiceTapKey.self] = newValue } } var centerImages: Bool { get { self[CenterImagesKey.self] } set { self[CenterImagesKey.self] = newValue } } } extension MDMark { var color: Color { switch self { case .ok: Palette.ok case .warn: Palette.warn case .danger: Palette.danger case .muted: Palette.muted } } var tint: Color { switch self { case .ok: Palette.okTint case .warn: Palette.warnTint case .danger: Palette.dangerTint case .muted: Palette.surface } } } private let httpLink = Re(#"^https?://"#, caseless: true) private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#) private let todoHref = Re(#"^#/todo/(\w+)/(.+)$"#) private let suggestHref = Re(#"^#/suggest/(\w+)/([^/]+)/(.+)$"#) private let anyScheme = Re(#"^[a-z][a-z0-9+.-]*:"#, caseless: true) private let pathLine = #"(?::\d+(?:[-:]\d+)?|#L\d+(?:-L?\d+)?)?"# private let pathFile = #"[\w+@-][\w.+@-]*\.[A-Za-z]\w*"# private let codePath = Re(#"^(?:\./)?((?:[\w.+@-]+/)+(?:"# + pathFile + ")?)" + pathLine + "$") private let barePath = Re(#"(? String? { var out: [String] = [] for part in path.split(separator: "/", omittingEmptySubsequences: true) { if part == "." { continue } if part == ".." { if out.popLast() == nil { return nil } continue } out.append(String(part)) } if out.isEmpty { return nil } return out.joined(separator: "/") + (path.hasSuffix("/") ? "/" : "") } @MainActor struct InlineRenderer { let store: Store let size: CGFloat let taskId: String? let weight: CGFloat let dir: String private let root: String init(store: Store, size: CGFloat, taskId: String?, weight: CGFloat = 400, dir: String = "") { self.store = store self.size = size self.taskId = taskId self.weight = weight self.dir = dir root = taskId.flatMap { store.findTask($0)?.worktreePath } ?? "" } private struct Style { var bold = false var italic = false var link: URL? var color: Color? var small = false var tip: String? } func segments(_ items: [MDInline]) -> [MDSegment] { var out: [MDSegment] = [] render(items, Style(), &out) return out } func text(_ items: [MDInline]) -> AttributedString { var out = AttributedString() for seg in segments(items) { if case .text(let a) = seg { out += a } } return out } private func run(_ s: String, _ style: Style) -> AttributedString { var a = AttributedString(s) a.font = Fonts.font(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic) a.mergeAttributes(try! AttributeContainer([.font: Fonts.ns(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic)], including: \.appKit)) if let link = style.link { a.link = link } if let color = style.color { a.foregroundColor = color a.appKit.foregroundColor = NSColor(color) } if let tip = style.tip { a.appKit.toolTip = tip } return a } private func append(_ a: AttributedString, _ out: inout [MDSegment]) { if case .text(let prev) = out.last { out[out.count - 1] = .text(prev + a) return } out.append(.text(a)) } private func render(_ items: [MDInline], _ style: Style, _ out: inout [MDSegment]) { for item in items { switch item { case .text(let t): text(t, style, &out) case .lineBreak: append(run("\n", style), &out) case .code(let c): var s = style if s.link == nil, let path = codeFile(c) { s.link = path s.color = Palette.accent } var a = run(c, s) a.backgroundColor = Palette.surface a.appKit.backgroundColor = NSColor(Palette.surface) append(a, &out) case .strong(let inner): var s = style s.bold = true render(inner, s, &out) case .em(let inner): var s = style s.italic = true render(inner, s, &out) case .image(let src, let alt): if let source = worktreeImageSource(src, taskId, dir: dir) { out.append(.image(source, src: src)) } else { link(src, [.text(alt.isEmpty ? src : alt)], style, &out) } case .link(let href, let inner): link(href, inner, style, &out) case .mark(let mark, let inner): var s = style s.color = mark.color render(inner, s, &out) } } } private func link(_ href: String, _ inner: [MDInline], _ style: Style, _ out: inout [MDSegment]) { var s = style if httpLink.test(href), let url = URL(string: href) { s.link = url s.color = Palette.accent render(inner, s, &out) return } if let m = taskHref.exec(href) { s.link = AppLink.route(href) s.color = store.taskColor(m[1]!) ?? Palette.accent render(inner, s, &out) return } if let m = todoHref.exec(href), let title = m[2]!.removingPercentEncoding { todo(m[1]!, title, nil, inner, style, &out) return } if let m = suggestHref.exec(href), let title = m[2]!.removingPercentEncoding, let brief = m[3]!.removingPercentEncoding { todo(m[1]!, title, brief, inner, style, &out) return } if href.hasPrefix("#/") { s.link = AppLink.route(href) s.color = Palette.accent render(inner, s, &out) return } if let url = hrefFile(href) { s.link = url s.color = Palette.accent render(inner, s, &out) return } render(inner, style, &out) } private func fileLink(_ path: String) -> URL? { guard let taskId, !root.isEmpty, let p = normalPath(path) else { return nil } return AppLink.file(taskId, p) } private func codeFile(_ code: String) -> URL? { var c = code if c.hasPrefix(root + "/") { c = String(c.dropFirst(root.count + 1)) if let m = rootedPath.exec(c), m[0] == c { return fileLink(m[1]!) } } return codePath.exec(c).flatMap { fileLink($0[1]!) } } private func hrefFile(_ href: String) -> URL? { let bare = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map { String($0).replacingOccurrences(of: #":\d+(?:[-:]\d+)?$"#, with: "", options: .regularExpression) } ?? "" if bare.isEmpty || anyScheme.test(bare) { return nil } guard var p = bare.removingPercentEncoding else { return nil } if p.hasPrefix("/") { guard p.hasPrefix(root + "/") else { return nil } p = String(p.dropFirst(root.count + 1)) } else if !dir.isEmpty { p = "\(dir)/\(p)" } return fileLink(p) } private func text(_ t: String, _ style: Style, _ out: inout [MDSegment]) { if style.link != nil || root.isEmpty { append(run(t, style), &out) return } var rest = t while !rest.isEmpty { var found = barePath.match(rest).map { (range: $0.range, path: $0.groups[1]!) } if let r = rest.range(of: root + "/"), found == nil || r.lowerBound <= found!.range.lowerBound { let tail = String(rest[r.upperBound...]) if let m = rootedPath.match(tail) { let end = rest.index(r.upperBound, offsetBy: tail.distance(from: tail.startIndex, to: m.range.upperBound)) found = (r.lowerBound.. AnyView)? var gridColumns = 4 var weight: CGFloat = 400 var dir = "" } private enum MDRun { case flow([MDBlock]) case block(MDBlock) } struct MarkdownView: View { let blocks: [MDBlock] var ctx = MDContext() @Environment(Store.self) private var store var body: some View { VStack(alignment: .leading, spacing: 8) { ForEach(Array(runs.enumerated()), id: \.offset) { _, run in switch run { case .flow(let blocks): FlowText(text: flow(blocks)) case .block(let block): BlockView(block: block, ctx: ctx) } } } .frame(maxWidth: .infinity, alignment: .leading) } private func flow(_ blocks: [MDBlock]) -> NSAttributedString { var builder = FlowBuilder(store: store, ctx: ctx) return builder.build(blocks) } private var runs: [MDRun] { var out: [MDRun] = [] for block in blocks { if !flows(block) { out.append(.block(block)) continue } if case .flow(let prev) = out.last { out[out.count - 1] = .flow(prev + [block]) continue } out.append(.flow([block])) } return out } private func flows(_ block: MDBlock) -> Bool { switch block { case .paragraph(let inlines), .heading(_, let inlines): return !hasImage(inlines) case .list(_, _, let items, _): if ctx.choice != nil && items.contains(where: { $0.choice != nil }) { return false } return items.allSatisfy { !hasImage($0.inline) && $0.blocks.allSatisfy(flows) } default: return false } } private func hasImage(_ inlines: [MDInline]) -> Bool { inlines.contains { item in switch item { case .image: true case .strong(let inner), .em(let inner), .link(_, let inner), .mark(_, let inner): hasImage(inner) default: false } } } } @MainActor private struct FlowBuilder { let store: Store let ctx: MDContext private var out = NSMutableAttributedString() private var gap: CGFloat = 0 init(store: Store, ctx: MDContext) { self.store = store self.ctx = ctx } mutating func build(_ blocks: [MDBlock]) -> NSAttributedString { self.blocks(blocks, 0) if out.length > 0 { out.deleteCharacters(in: NSRange(location: out.length - 1, length: 1)) } return out } private mutating func blocks(_ blocks: [MDBlock], _ indent: CGFloat) { for (i, block) in blocks.enumerated() { if i > 0 { gap = 8 } self.block(block, indent) } } private mutating func block(_ block: MDBlock, _ indent: CGFloat) { switch block { case .paragraph(let inlines): para(inline(inlines, ctx.size), indent, indent) case .heading(let level, let inlines): gap += 4 para(inline([.strong(inlines)], level == 3 ? 14 : 13), indent, indent) case .list(let ordered, let start, let items, _): let font = Fonts.ns(ctx.size) for (n, item) in items.enumerated() { if n > 0 { gap = 2 } let marker = ordered ? "\(start + n)." : "•" let inner = indent + ceil((marker as NSString).size(withAttributes: [.font: font]).width) + 6 let line = NSMutableAttributedString(string: "\(marker)\t", attributes: [.font: font]) line.append(inline(item.inline, ctx.size)) para(line, indent, inner) gap = 6 blocks(item.blocks, inner) } default: break } } private func inline(_ inlines: [MDInline], _ size: CGFloat) -> NSAttributedString { let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId, weight: ctx.weight, dir: ctx.dir).text(inlines) return try! NSAttributedString(a, including: \.appKit) } private mutating func para(_ a: NSAttributedString, _ first: CGFloat, _ head: CGFloat) { let s = NSMutableAttributedString(attributedString: a) s.append(NSAttributedString(string: "\n", attributes: [.font: Fonts.ns(ctx.size)])) let all = NSRange(location: 0, length: s.length) flowColor(s, NSColor(ctx.color)) let lead = (s.string as NSString).range(of: "\n").location + 1 s.addAttribute(.paragraphStyle, value: style(first, head, 0), range: all) s.addAttribute(.paragraphStyle, value: style(first, head, gap), range: NSRange(location: 0, length: lead)) out.append(s) gap = 0 } private func style(_ first: CGFloat, _ head: CGFloat, _ before: CGFloat) -> NSParagraphStyle { let p = NSMutableParagraphStyle() p.lineSpacing = 3 p.paragraphSpacingBefore = before p.firstLineHeadIndent = first p.headIndent = head p.tabStops = [NSTextTab(textAlignment: .left, location: head)] return p } } private final class FlowTextView: NSTextView { var onTap: (@MainActor @Sendable () -> Void)? let storage: NSTextStorage private let layout: NSLayoutManager private let container: NSTextContainer init() { let storage = NSTextStorage() let layout = NSLayoutManager() let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) container.lineFragmentPadding = 0 container.widthTracksTextView = true storage.addLayoutManager(layout) layout.addTextContainer(container) self.storage = storage self.layout = layout self.container = container super.init(frame: .zero, textContainer: container) } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func resignFirstResponder() -> Bool { setSelectedRange(NSRange(location: 0, length: 0)) return super.resignFirstResponder() } override func cursorUpdate(with event: NSEvent) { guard frontmost(event) else { NSCursor.arrow.set() return } super.cursorUpdate(with: event) } override func mouseMoved(with event: NSEvent) { guard frontmost(event) else { return } super.mouseMoved(with: event) } private func frontmost(_ event: NSEvent) -> Bool { unsafe window?.contentView?.hitTest(event.locationInWindow)?.isDescendant(of: self) == true } override func mouseDown(with event: NSEvent) { let onLink = link(at: convert(event.locationInWindow, from: nil)) super.mouseDown(with: event) if let onTap, !onLink, selectedRange().length == 0 { onTap() } } private func link(at point: NSPoint) -> Bool { guard storage.length > 0 else { return false } let p = NSPoint(x: point.x - textContainerOrigin.x, y: point.y - textContainerOrigin.y) let g = layout.glyphIndex(for: p, in: container) if !layout.boundingRect(forGlyphRange: NSRange(location: g, length: 1), in: container).contains(p) { return false } let i = layout.characterIndexForGlyph(at: g) if i >= storage.length { return false } let value = unsafe storage.attribute(.link, at: i, effectiveRange: nil) return value != nil } } private struct FlowText: NSViewRepresentable { let text: NSAttributedString func makeNSView(context: Context) -> FlowTextView { let v = FlowTextView() v.isEditable = false v.isSelectable = true v.drawsBackground = false v.isVerticallyResizable = false v.isHorizontallyResizable = false v.textContainerInset = .zero v.linkTextAttributes = [.cursor: NSCursor.pointingHand] v.delegate = context.coordinator return v } func updateNSView(_ v: FlowTextView, context: Context) { context.coordinator.openURL = context.environment.openURL v.onTap = context.environment.choiceTap if v.storage.isEqual(to: text) { return } let selected = v.selectedRanges v.storage.setAttributedString(text) if selected.allSatisfy({ NSMaxRange($0.rangeValue) <= text.length }) { v.selectedRanges = selected } } func sizeThatFits(_ proposal: ProposedViewSize, nsView v: FlowTextView, context: Context) -> CGSize? { context.coordinator.size(text, proposal.width.flatMap { $0.isFinite ? $0 : nil }) } func makeCoordinator() -> Coordinator { Coordinator() } final class Coordinator: NSObject, NSTextViewDelegate { var openURL: OpenURLAction? private let storage = NSTextStorage() private let layout = NSLayoutManager() private let container = NSTextContainer() private var measured: (text: NSAttributedString, width: CGFloat?, size: CGSize)? override init() { super.init() container.lineFragmentPadding = 0 layout.addTextContainer(container) storage.addLayoutManager(layout) } func size(_ text: NSAttributedString, _ width: CGFloat?) -> CGSize { let same = measured.map { $0.text === text || $0.text.isEqual(to: text) } ?? false if same, let m = measured, m.width == width { return m.size } if !same { storage.setAttributedString(text) } container.size = CGSize(width: width ?? .greatestFiniteMagnitude, height: .greatestFiniteMagnitude) layout.ensureLayout(for: container) let used = layout.usedRect(for: container) let size = CGSize(width: width ?? ceil(used.width), height: ceil(used.height)) measured = (text, width, size) return size } func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { guard let url = link as? URL, let openURL else { return false } openURL(url) return true } } } struct InlineView: View { let inlines: [MDInline] let ctx: MDContext @Environment(Store.self) private var store @Environment(\.choiceTap) private var choiceTap @Environment(\.centerImages) private var centerImages var body: some View { let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId, weight: ctx.weight, dir: ctx.dir).segments(inlines) VStack(alignment: .leading, spacing: 4) { ForEach(Array(segs.enumerated()), id: \.offset) { _, seg in switch seg { case .text(let a): if choiceTap != nil { FlowText(text: flowString(a)) } else { Text(a) .font(Fonts.font(ctx.size)) .foregroundStyle(ctx.color) .lineSpacing(3) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } case .image(let source, let src): if centerImages { ByreImage(source: source, src: src) .frame(maxWidth: .infinity) } else { ByreImage(source: source, src: src) } } } } } private func flowString(_ a: AttributedString) -> NSAttributedString { let s = NSMutableAttributedString(attributedString: try! NSAttributedString(a, including: \.appKit)) flowColor(s, NSColor(ctx.color)) let p = NSMutableParagraphStyle() p.lineSpacing = 3 s.addAttribute(.paragraphStyle, value: p, range: NSRange(location: 0, length: s.length)) return s } } private struct BlockView: View { let block: MDBlock let ctx: MDContext var body: some View { switch block { case .paragraph(let inlines): InlineView(inlines: inlines, ctx: ctx) case .heading(let level, let inlines): InlineView(inlines: [.strong(inlines)], ctx: MDContext(size: level == 3 ? 14 : 13, color: ctx.color, taskId: ctx.taskId, dir: ctx.dir)) .padding(.top, 4) case .rule: Rectangle() .fill(Palette.border) .frame(height: 1) .padding(.vertical, 2) case .quote(let blocks): MarkdownView(blocks: blocks, ctx: MDContext(size: ctx.size, color: Palette.muted, taskId: ctx.taskId, dir: ctx.dir)) .padding(.leading, 13) .overlay(alignment: .leading) { Rectangle().fill(Palette.border).frame(width: 3) } case .list(let ordered, let start, let items, let grid): ListBlock(ordered: ordered, start: start, items: items, grid: grid, ctx: ctx) case .code(let code): Copyable(text: code) { ScrollView(.horizontal) { Text(verbatim: code) .font(Fonts.font(ctx.size)) .foregroundStyle(Palette.text) .textSelection(.enabled) .fixedSize() .padding(.horizontal, 8) .padding(.vertical, 6) } .scrollBounceBehavior(.basedOnSize) .frame(maxWidth: .infinity, alignment: .leading) .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) } case .diff(let lines): DiffBlock(lines: lines, size: ctx.size) case .details(let summary, let body): DetailsBlock(summary: summary, blocks: body, ctx: ctx) case .diagram(let source): DiagramView(source: source) case .table(let head, let rows): TableBlock(head: head, rows: rows, ctx: ctx) } } } private struct ListBlock: View { let ordered: Bool let start: Int let items: [MDItem] let grid: Bool let ctx: MDContext var body: some View { if grid, let choice = ctx.choice { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: ctx.gridColumns), spacing: 8) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in choice(item, true) } } .environment(\.centerImages, true) } else { VStack(alignment: .leading, spacing: 2) { ForEach(Array(items.enumerated()), id: \.offset) { n, item in if item.choice != nil, let choice = ctx.choice { choice(item, n == 0) } else { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(verbatim: ordered ? "\(start + n)." : "•") .font(Fonts.font(ctx.size)) .foregroundStyle(ctx.color) ItemBody(item: item, ctx: ctx) } } } } } } } struct ItemBody: View { let item: MDItem let ctx: MDContext var body: some View { VStack(alignment: .leading, spacing: 6) { if !item.inline.isEmpty { InlineView(inlines: item.inline, ctx: ctx) } if !item.blocks.isEmpty { MarkdownView(blocks: item.blocks, ctx: ctx) } } .frame(maxWidth: .infinity, alignment: .leading) } } private struct DiffBlock: View { let lines: [String] let size: CGFloat @ViewState private var width: CGFloat = 0 var body: some View { Copyable(text: lines.map { $0 + "\n" }.joined()) { ScrollView(.horizontal) { StretchColumn(minWidth: width) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in Text(verbatim: line.isEmpty ? " " : line) .font(Fonts.font(size)) .foregroundStyle(line.hasPrefix("@@") || line.hasPrefix("+++") || line.hasPrefix("---") ? Palette.muted : Palette.text) .textSelection(.enabled) .fixedSize() .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(background(line)) } } .padding(.vertical, 6) } .scrollBounceBehavior(.basedOnSize) .frame(maxWidth: .infinity, alignment: .leading) .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 } .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) } } private func background(_ l: String) -> Color { if l.hasPrefix("@@") { return Palette.surface } if l.hasPrefix("+++") || l.hasPrefix("---") { return .clear } if l.hasPrefix("+") { return Palette.ok.opacity(0.14) } if l.hasPrefix("-") { return Palette.danger.opacity(0.14) } return .clear } } private struct DetailsBlock: View { let summary: [MDInline] let blocks: [MDBlock] let ctx: MDContext @ViewState private var open = false var body: some View { VStack(alignment: .leading, spacing: 4) { Button { open.toggle() } label: { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(open ? "▾" : "▸") .foregroundStyle(Palette.accent) InlineView(inlines: summary, ctx: MDContext(size: ctx.size, color: Palette.accent, taskId: ctx.taskId, dir: ctx.dir)) } .contentShape(Rectangle()) } .buttonStyle(.plain) if open { MarkdownView(blocks: blocks, ctx: ctx) .padding(.leading, 11) .overlay(alignment: .leading) { Rectangle().fill(Palette.border).frame(width: 1) } } } .padding(.vertical, 2) } } private struct TableBlock: View { let head: [[MDInline]] let rows: [[[MDInline]]] let ctx: MDContext var body: some View { ViewThatFits(in: .horizontal) { grid.fixedSize(horizontal: true, vertical: false) grid } .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } private var grid: some View { Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) { GridRow { ForEach(Array(head.enumerated()), id: \.offset) { _, cell in cellView([.strong(cell)]) .background(tint(cell) ?? Palette.surface) } } ForEach(Array(rows.enumerated()), id: \.offset) { _, row in GridRow { ForEach(Array(row.enumerated()), id: \.offset) { _, cell in cellView(cell) .background(tint(cell) ?? .clear) } } } } .overlay(Rectangle().strokeBorder(Palette.border)) } private func tint(_ cell: [MDInline]) -> Color? { guard case .mark(let mark, _) = cell.first else { return nil } return mark.tint } private func cellView(_ cell: [MDInline]) -> some View { InlineView(inlines: cell, ctx: ctx) .padding(.horizontal, 8) .padding(.vertical, 3) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(Rectangle().stroke(Palette.border, lineWidth: 0.5)) } }