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
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user