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:
+18
-1
@@ -6,4 +6,21 @@ background: {color: coral}
|
|||||||
icon: flag.fill
|
icon: flag.fill
|
||||||
iconColor: orange
|
iconColor: orange
|
||||||
---
|
---
|
||||||
Plain, unquoted title this time — mixing styles on purpose.
|
## Stray tolerance
|
||||||
|
|
||||||
|
Plain, unquoted title this time — mixing styles on purpose, and a body rich enough to exercise
|
||||||
|
every block the mobile Preview renders.
|
||||||
|
|
||||||
|
- [x] Skip `.DS_Store` and other dotfiles
|
||||||
|
- [ ] Warn on non-UUID folder names
|
||||||
|
|
||||||
|
> Any folder that isn't a UUID is treated as an opaque stray, never parsed as an item.
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| Dotfile | Skipped silently |
|
||||||
|
| Non-UUID folder | Treated as a stray |
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let isUUID = UUID(uuidString: name) != nil
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
**August 2026**
|
**August 2026**
|
||||||
|
|
||||||
|
Card text now renders full Markdown — headings, lists with checkboxes, quotes, tables, and code blocks — instead of plain paragraphs.
|
||||||
|
|
||||||
Tapping a card now shows a readable view of its title and text with formatting, instead of dropping straight into the editor.
|
Tapping a card now shows a readable view of its title and text with formatting, instead of dropping straight into the editor.
|
||||||
|
|
||||||
Editing a card happens on its own screen with Details and Body sections, and changes apply only when you tap Save.
|
Editing a card happens on its own screen with Details and Body sections, and changes apply only when you tap Save.
|
||||||
|
|||||||
@@ -75,18 +75,32 @@ struct CardDetailScreen: View {
|
|||||||
/// card's own `background` swatch — passive display only, no swatches to tap here. An
|
/// card's own `background` swatch — passive display only, no swatches to tap here. An
|
||||||
/// unrecognized colour name (the same leniency `ItemIconView`/`CardPalette` document for
|
/// unrecognized colour name (the same leniency `ItemIconView`/`CardPalette` document for
|
||||||
/// themselves) just skips the tint rather than guessing at one.
|
/// themselves) just skips the tint rather than guessing at one.
|
||||||
|
///
|
||||||
|
/// **Alignment constraint.** The title's leading edge has to land exactly where the body's
|
||||||
|
/// paragraphs land, one block below — both sit at nothing but the `ScrollView`'s own outer
|
||||||
|
/// `.padding()`, so this view carries no padding of its own around the icon/text content. The
|
||||||
|
/// tint still wants breathing room rather than painting flush against the letterforms, but
|
||||||
|
/// that room can't come from padding the content — that's exactly what would reintroduce the
|
||||||
|
/// indent this fixes. It comes from the tint shape growing *outward* past the content's own
|
||||||
|
/// bounds instead, via a negative inset in `.background`, which is invisible to layout: the
|
||||||
|
/// row's frame — and so the title's leading edge — is unaffected by how far its background
|
||||||
|
/// paints past it. A card with no tint (most of them) renders with zero indent, flush with the
|
||||||
|
/// body below; a tinted card's title sits in exactly the same place, just with a soft wash
|
||||||
|
/// bleeding past its edges.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func titleBlock(for card: Card) -> some View {
|
private func titleBlock(for card: Card) -> some View {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||||
ItemIconView(icon: card.icon.value, iconColor: card.iconColor.value)
|
ItemIconView(icon: card.icon.value, iconColor: card.iconColor.value)
|
||||||
titleText(for: card)
|
titleText(for: card)
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background {
|
||||||
(tintColor(for: card)?.opacity(0.15) ?? Color.clear),
|
if let tintColor = tintColor(for: card) {
|
||||||
in: RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
)
|
.fill(tintColor.opacity(0.15))
|
||||||
|
.padding(-10)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -105,21 +119,11 @@ struct CardDetailScreen: View {
|
|||||||
card.background.value.flatMap { CardPalette.color(named: $0, in: CardPalette.backgrounds) }
|
card.background.value.flatMap { CardPalette.color(named: $0, in: CardPalette.backgrounds) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The body, rendered as Markdown — see `MarkdownBlocks` for why this is a per-paragraph
|
/// The body, rendered as Markdown — see `CardBodyView` for the block-by-block renderer this
|
||||||
/// `AttributedString(markdown:)` pass rather than a full block parser.
|
/// delegates to, built on the same `BodyMarkup` model the Mac's Preview surface parses with.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func bodyBlock(for card: Card) -> some View {
|
private func bodyBlock(for card: Card) -> some View {
|
||||||
let paragraphs = MarkdownBlocks.paragraphs(in: card.body)
|
CardBodyView(body: card.body)
|
||||||
if paragraphs.isEmpty {
|
|
||||||
Text("No description")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
ForEach(Array(paragraphs.enumerated()), id: \.offset) { _, paragraph in
|
|
||||||
Text(MarkdownBlocks.rendered(paragraph))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -137,34 +141,3 @@ struct CardDetailScreen: View {
|
|||||||
date.formatted(date: .abbreviated, time: .shortened)
|
date.formatted(date: .abbreviated, time: .shortened)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dependency-free Markdown rendering for a card body: no `swift-markdown` block parser here, just
|
|
||||||
/// `Foundation`'s own `AttributedString(markdown:options:)` run once per paragraph.
|
|
||||||
///
|
|
||||||
/// **Why per-paragraph.** `AttributedString`'s built-in parser has no "render this as a sequence of
|
|
||||||
/// blocks" mode short of `.full`, which produces a block tree this view would still have to walk —
|
|
||||||
/// no simpler than doing the walk ourselves, and it collapses the very newlines a card body relies
|
|
||||||
/// on to separate paragraphs. Splitting on blank lines first and parsing each paragraph with
|
|
||||||
/// `.inlineOnlyPreservingWhitespace` gets both halves of what a card body needs cheaply: inline
|
|
||||||
/// emphasis/links/code render as styled runs, and the newlines *inside* a paragraph (soft line
|
|
||||||
/// breaks) survive as literal whitespace instead of being folded into a single space.
|
|
||||||
enum MarkdownBlocks {
|
|
||||||
|
|
||||||
/// Splits `body` on runs of one or more blank lines — Markdown's own paragraph boundary — and
|
|
||||||
/// trims each block, so a body with trailing blank lines or extra spacing between paragraphs
|
|
||||||
/// doesn't render phantom empty blocks.
|
|
||||||
static func paragraphs(in body: String) -> [String] {
|
|
||||||
body
|
|
||||||
.components(separatedBy: "\n\n")
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One paragraph, inline-parsed. Malformed Markdown (an unterminated code span, say) falls back
|
|
||||||
/// to the raw paragraph text rather than dropping it — the same "never lose the author's text"
|
|
||||||
/// posture `CardPalette.color(named:in:)` takes for an unrecognized colour name.
|
|
||||||
static func rendered(_ paragraph: String) -> AttributedString {
|
|
||||||
let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
|
||||||
return (try? AttributedString(markdown: paragraph, options: options)) ?? AttributedString(paragraph)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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