Files
lanework/KanbanMobile/UI/CardBodyView.swift
T
rzen 2516c4ba5d The phone learns to read whole blocks — CardBodyView renders BodyMarkup's full tree, and the title falls in line
The read view's inline-only AttributedString shim gives way to a real
block renderer over BodyMarkup — the same parse the Mac Preview trusts,
whose swift-markdown dependency project.yml said was already riding
along for exactly this surface. Headings, nested lists with static task
checkboxes, quotes, GFM tables with per-column alignment and clamped
colspans, sideways-scrolling code blocks, literal HTML, dividers, and
tappable absolute links (relative ones stay prose — no folder to
resolve against). The title block sheds its inner padding so its
leading edge sits flush with the body; the tint now grows outward via
a negative background inset instead of pushing the text in. One rich
fixture card body enriched to exercise every block kind; Mac fixture
round-trip suites verified green against it.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-08 15:22:36 -04:00

444 lines
18 KiB
Swift

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: `<b>x</b>` 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
}
}