import SwiftUI /// A card body, rendered — the phone's counterpart to the Mac's `BodyMarkupRenderer`, and the /// anticipated surface `project.yml`'s dependency comment names: "`BodyMarkup` imports `Markdown`, /// so the dependency rides along even though the MVP has no preview surface yet." This is that /// surface arriving. /// /// **Same model, different substrate, same semantics where it matters.** `BodyMarkup.parse(_:)` — /// the shared parse the Mac's Preview also runs — already made every decision that is not /// typography: HTML is literal text, an image is never fetched, a task's live offset lives on the /// model rather than the render. What is left here is exactly what was left for /// `BodyMarkupRenderer`: turning a `[BodyBlock]` into pixels, on a different toolkit (SwiftUI /// `Text`/`AttributedString` rather than `NSTextView`/`NSTextTable`), and read-only — there is no /// tap-to-toggle checkbox this pass; a static SF Symbol stands in, and flipping it live is a /// separate future decision (see `TaskMarkerView` below). /// /// **Why a block renderer at all, and not the inline-only `AttributedString(markdown:)` shim this /// replaces.** That shim split on blank lines and parsed each paragraph as inline Markdown, which /// covers emphasis, links, and code spans but nothing block-shaped: a card body with a heading, a /// fenced code sample, a list, or a table rendered as an indistinguishable paragraph of stray /// punctuation. `BodyMarkup` already parses the full block tree — the walk this view does is the /// one the shim's own doc comment predicted would be "no simpler than doing the walk ourselves", /// except now there is a real block tree to walk instead of a hand-split string. struct CardBodyView: View { private let blocks: [BodyBlock] init(body: String) { blocks = BodyMarkup.parse(body).blocks } var body: some View { if blocks.isEmpty { Text("No description") .foregroundStyle(.secondary) } else { BlockListView(blocks: blocks, spacing: 14) } } } // MARK: - Block layout /// A run of sibling blocks, stacked top to bottom — the shape every block-container case in /// `BodyBlock` recurses through: the document's own top level, a list item's content, a quote's /// nested blocks, a table cell. One view handles all of them, which is what makes the recursion /// (quotes inside quotes, lists inside lists) fall out for free rather than needing a depth /// parameter threaded everywhere. private struct BlockListView: View { let blocks: [BodyBlock] var spacing: CGFloat = 10 var body: some View { VStack(alignment: .leading, spacing: spacing) { ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in BlockView(block: block) } } } } private struct BlockView: View { let block: BodyBlock var body: some View { switch block { case let .heading(level, inlines, _): Text(InlineText.render(inlines, font: Self.headingFont(level: level))) .padding(.top, level <= 2 ? 4 : 0) case let .paragraph(inlines, _): Text(InlineText.render(inlines)) case let .code(code, _, _): LiteralBlockView(text: Self.trimmingTrailingNewlines(code), color: .primary) case let .html(raw, _): // Verbatim, same rule the model itself makes: `x` is code-styled characters, // never a rendered bold. Dimmed relative to code to read as "the author's markup", // not "content" — the Mac renderer's own choice for this case. LiteralBlockView(text: Self.trimmingTrailingNewlines(raw), color: .secondary) case .thematicBreak: Divider() .padding(.vertical, 4) case let .quote(blocks, _): QuoteView(blocks: blocks) case let .list(list, _): ListView(list: list) case let .table(table, _): TableView(table: table) } } /// The heading ladder the spec asks for: a three-step title2/title3/headline run rather than /// the Mac's continuous four-step scale, because that is the whole of SwiftUI's built-in text /// style vocabulary above body weight — anything past level 3 stays at `.headline` rather than /// inventing a fourth size nothing else on the screen uses. fileprivate static func headingFont(level: Int) -> Font { switch level { case 1: .title2.weight(.bold) case 2: .title3.weight(.bold) default: .headline } } fileprivate static func trimmingTrailingNewlines(_ text: String) -> String { var text = text while text.hasSuffix("\n") || text.hasSuffix("\r") { text.removeLast() } return text } } /// A fenced/indented code block or a literal HTML block: monospaced, in a soft rounded container, /// scrolling sideways rather than wrapping — the shape a code sample or a raw tag soup needs to /// stay legible instead of ricocheting across narrow phone-width lines. private struct LiteralBlockView: View { let text: String let color: Color var body: some View { ScrollView(.horizontal, showsIndicators: false) { Text(text) .font(.system(.callout, design: .monospaced)) .foregroundStyle(color) .padding(10) } .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8)) } } /// A block quote: a leading accent bar beside its nested blocks, recursing through `BlockListView` /// for whatever those blocks are — including another quote, which is what makes a quote-in-a-quote /// read as two bars rather than something this view has to special-case. private struct QuoteView: View { let blocks: [BodyBlock] var body: some View { HStack(alignment: .top, spacing: 10) { Capsule() .fill(Color.secondary.opacity(0.35)) .frame(width: 3) BlockListView(blocks: blocks, spacing: 8) } } } // MARK: - Lists /// A bullet, ordered, or task list. Nested lists are not a case this view special-cases either: /// `BodyListItem.blocks` recurses through `BlockListView` just like a quote's blocks do, and /// because a nested list lands *inside* the marker/content `HStack` of the item that owns it, the /// indentation the spec asks for ("nested lists indent") falls out of that layout rather than /// needing a manually tracked depth. private struct ListView: View { let list: BodyList var body: some View { VStack(alignment: .leading, spacing: 6) { ForEach(Array(list.items.enumerated()), id: \.offset) { offset, item in ListItemView(item: item, marker: marker(for: item, index: offset)) } } } private func marker(for item: BodyListItem, index: Int) -> ListMarker { if let task = item.task { return .task(isChecked: task.isChecked) } return list.isOrdered ? .number(list.start + index) : .bullet } } private enum ListMarker { case bullet case number(Int) case task(isChecked: Bool) } private struct ListItemView: View { let item: BodyListItem let marker: ListMarker var body: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { markerView if item.blocks.isEmpty { // A bare `- [ ]` with no label yet is still a checkbox the user wrote — keep the // marker on screen rather than collapsing the row to nothing, the same posture // `BodyMarkupRenderer.append(_:BodyList:...)` takes on the Mac. Text(" ") } else { BlockListView(blocks: item.blocks, spacing: 8) } } } @ViewBuilder private var markerView: some View { switch marker { case .bullet: Text("•") .foregroundStyle(.secondary) .accessibilityHidden(true) case let .number(number): Text("\(number).") .foregroundStyle(.secondary) .monospacedDigit() .accessibilityHidden(true) case let .task(isChecked): TaskMarkerView(isChecked: isChecked) } } } /// A task-list checkbox, drawn but not driven: `checkmark.square.fill` when checked, `square` /// otherwise, both in a secondary tint. The Mac's Preview makes this glyph clickable — a `.link` /// URL that round-trips through `CardBodyLink` to flip the one byte `BodyTask.markerOffset` names /// — but that write path (and the re-verification `BoardWriter.toggleTaskMarker` does against /// disk before committing it) is deliberately not part of this pass: `CardDetailScreen` is a /// read-only screen with no session write of its own, and giving one glyph inside it a live write /// would be a bigger decision than this pass is scoped to make. A future pass that wants tappable /// checkboxes here can lean on the same model-carried offset; nothing about `BodyTask` needs to /// change to support it. private struct TaskMarkerView: View { let isChecked: Bool var body: some View { Image(systemName: isChecked ? "checkmark.square.fill" : "square") .foregroundStyle(.secondary) .accessibilityLabel(isChecked ? "Completed task" : "Incomplete task") } } // MARK: - Tables /// A GFM table as a `Grid`: per-column alignment from `BodyTable.alignments`, a bold header row, /// and a colspan handled with `.gridCellColumns` — `Grid`'s own multi-column span primitive, which /// is to this layout what `NSTextTable`'s automatic sizing is to the Mac's: the one built-in /// mechanism that makes a hand-rolled tab-stop table unnecessary. Wrapped in a horizontal /// `ScrollView` because a table sized to its content is routinely wider than a phone screen. private struct TableView: View { let table: BodyTable private var columnCount: Int { max(1, table.columnCount) } var body: some View { ScrollView(.horizontal, showsIndicators: false) { Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 8) { row(table.header, isHeader: true) Divider().gridCellColumns(columnCount) ForEach(Array(table.rows.enumerated()), id: \.offset) { _, cells in row(cells, isHeader: false) } } .padding(.vertical, 2) } } @ViewBuilder private func row(_ cells: [BodyTableCell], isHeader: Bool) -> some View { GridRow { ForEach(positioned(cells), id: \.column) { positioned in cellView(positioned.cell, column: positioned.column, span: positioned.span, isHeader: isHeader) } } } /// Cells with their starting grid column and clamped span, walked left to right so a colspan /// earlier in the row shifts every cell after it — the same bookkeeping `BodyTableRenderer`'s /// `NSTextTableBlock(startingColumn:columnSpan:)` construction does on the Mac, done here in /// terms `Grid` understands. A span that would run past the table's own column count is /// clamped rather than trusted — "handled at least degenerately, don't crash" — and a row with /// more cells than there is room for simply stops placing them. private func positioned(_ cells: [BodyTableCell]) -> [(cell: BodyTableCell, column: Int, span: Int)] { var column = 0 var result: [(cell: BodyTableCell, column: Int, span: Int)] = [] for cell in cells where column < columnCount { let span = max(1, min(cell.colspan, columnCount - column)) result.append((cell, column, span)) column += span } return result } @ViewBuilder private func cellView(_ cell: BodyTableCell, column: Int, span: Int, isHeader: Bool) -> some View { let alignment = table.alignments.indices.contains(column) ? table.alignments[column] : .unspecified Text(InlineText.render(cell.inlines, font: isHeader ? .body.weight(.semibold) : .body)) .multilineTextAlignment(textAlignment(alignment)) .gridColumnAlignment(horizontalAlignment(alignment)) .gridCellColumns(span) } private func horizontalAlignment(_ alignment: BodyTable.Alignment) -> HorizontalAlignment { switch alignment { case .unspecified, .leading: .leading case .center: .center case .trailing: .trailing } } private func textAlignment(_ alignment: BodyTable.Alignment) -> TextAlignment { switch alignment { case .unspecified, .leading: .leading case .center: .center case .trailing: .trailing } } } // MARK: - Inlines /// `[BodyInline]` → one `AttributedString`, the whole of the phone's inline vocabulary in a single /// recursive walk. Traits (bold/italic/strikethrough) are carried down as state and composed at /// the leaves rather than each node picking its own font, the same reasoning /// `BodyMarkupRenderer.InlineTraits` gives on the Mac: `**bold _and italic_**` nests two inline /// cases around one run of text, and the run needs to know about both by the time it is drawn. private enum InlineText { struct Traits { var isBold = false var isItalic = false var isStruck = false } static func render(_ inlines: [BodyInline], font: Font = .body) -> AttributedString { var result = AttributedString() append(inlines, into: &result, font: font, traits: Traits()) return result } private static func append( _ inlines: [BodyInline], into result: inout AttributedString, font: Font, traits: Traits ) { for inline in inlines { append(inline, into: &result, font: font, traits: traits) } } private static func append( _ inline: BodyInline, into result: inout AttributedString, font: Font, traits: Traits ) { switch inline { case let .text(text): result += run(text, font: font, traits: traits) case let .emphasis(children): var inner = traits inner.isItalic = true append(children, into: &result, font: font, traits: inner) case let .strong(children): var inner = traits inner.isBold = true append(children, into: &result, font: font, traits: inner) case let .strikethrough(children): var inner = traits inner.isStruck = true append(children, into: &result, font: font, traits: inner) case let .code(code): var span = AttributedString(code) span.font = .system(.body, design: .monospaced) span.backgroundColor = Color(.systemGray5) if traits.isStruck { span.strikethroughStyle = .single } result += span case let .html(raw): // Same rule as the block form: what the author typed is what the reader sees, styled // to read as markup rather than prose. var span = AttributedString(raw) span.font = .system(.body, design: .monospaced) span.foregroundColor = .secondary span.backgroundColor = Color(.systemGray5) result += span case .lineBreak: // A hard break: a line of its own. result += run("\n", font: font, traits: traits) case .softBreak: // An ordinary newline in the source, a space to the reader — the same fold the Mac // renderer performs (`NSAttributedString` has no soft-wrap character either; the // difference from a hard break there is exactly this same one-space-versus-a-line // choice, made in `AttributedString` terms here instead of `NSAttributedString`'s). result += run(" ", font: font, traits: traits) case let .link(target, children): var span = AttributedString() append(children, into: &span, font: font, traits: traits) if span.characters.isEmpty { span = run(target.text, font: font, traits: traits) } // Only a scheme-carrying destination becomes a live `.link` — a relative path (a // sibling file in the card's own folder, the Mac's "open with its default app" case) // has nothing to resolve against here: this initializer takes only the body string, // no card folder, and a phone app has no "open this arbitrary file with its default // app" affordance to hand it to regardless. It still reads as the label the author // wrote; it just is not tappable, rather than guessing at a destination. if case .absolute = target, let url = URL(string: target.text) { span.link = url // The children were appended with an explicit `.primary` foreground (every run // gets one), and an explicit colour beats the tint `Text` would otherwise paint a // `.link` run with — clearing it is what lets a live link *look* live instead of // rendering as plain body text that happens to respond to a tap. span.foregroundColor = nil } result += span case let .image(image): // No image loading this pass, on either fork of `BodyTarget` — the alt text (or the // raw destination, if the author left no alt) stands in behind a paperclip glyph, so // the reader learns what is not being shown without this view touching the network or // the filesystem. let label = image.alt.isEmpty ? image.target.text : image.alt var span = AttributedString("\u{1F4CE} \(label)") span.font = font span.foregroundColor = .secondary result += span } } private static func run(_ text: String, font: Font, traits: Traits) -> AttributedString { var span = AttributedString(text) var styledFont = font if traits.isBold { styledFont = styledFont.bold() } if traits.isItalic { styledFont = styledFont.italic() } span.font = styledFont span.foregroundColor = .primary if traits.isStruck { span.strikethroughStyle = .single } return span } }