import AppKit import Foundation // MARK: - The clickable-run codec /// The two kinds of thing a click in Preview can land on, encoded as URLs so that **AppKit's own /// link hit-testing** is the whole of the click grammar (05-card-window.md ▸ Preview: "clicking the /// rendered body selects text … and nothing else. The one interactive exception is task-list /// checkboxes"). /// /// Encoding the checkbox as a link rather than tracking mouse events by hand is the point: a text /// view already knows which character was clicked, already tells its delegate, already declines to /// fire when the user was dragging out a selection, and already leaves every other character /// selectable. Re-implementing that from `mouseDown` would be re-implementing text selection. enum CardBodyLink { /// A scheme nothing on the system claims, so a checkbox can never be handed to `NSWorkspace` /// by a path that forgot to check. static let taskScheme = "x-lanework-task" /// `x-lanework-task:/<0 or 1>` — the offset the flip will aim at, and the state /// the user is looking at, which is what `BoardWriter.toggleTaskMarker` re-verifies against /// disk before it writes. static func task(offset: Int, isChecked: Bool) -> URL? { URL(string: "\(taskScheme):\(offset)/\(isChecked ? 1 : 0)") } /// The inverse. `nil` for every URL that is not one of ours — which is every ordinary link in /// a card body, and is how the delegate tells the two apart. static func parseTask(_ url: URL) -> (offset: Int, isChecked: Bool)? { guard url.scheme == taskScheme else { return nil } let parts = url.absoluteString.dropFirst(taskScheme.count + 1).split(separator: "/") guard parts.count == 2, let offset = Int(parts[0]), let state = Int(parts[1]) else { return nil } return (offset, state == 1) } } // MARK: - BodyMarkupRenderer /// `BodyMarkup` → `NSAttributedString`: the drawing half of Preview, and **only** the drawing half. /// /// Every decision that is not typography was already made in `BodyMarkup` — what is HTML, what is a /// remote image, where a checkbox's byte lives — which is what keeps this file free of policy and /// makes it the one part of Preview that is legitimately unverifiable by a unit test. What it does /// own is the app's type scale: body text in the system body font (the same `CardWindowMetrics` /// point size the whole window derives from), code in a monospaced face, and every indent, padding /// and image cap a multiple of that one size, so Preview grows with the system text size like the /// rest of the window (10-accessibility.md ▸ Text). @MainActor enum BodyMarkupRenderer { /// What a render needs beyond the markup itself: how big the body font is, and where the card's /// own folder is — the anchor every relative image and link resolves against (05 ▸ Preview). struct Context { var pointSize: CGFloat var cardFolder: URL? init(pointSize: CGFloat, cardFolder: URL?) { self.pointSize = pointSize self.cardFolder = cardFolder } } // MARK: Entry point static func attributedString(for markup: BodyMarkup, context: Context) -> NSAttributedString { let output = NSMutableAttributedString() var frame = Frame(context: context) append(markup.blocks, into: output, frame: &frame) return output } /// The raw Markdown as the **Edit placeholder** shows it: monospaced, unhighlighted, and /// character for character what is on disk. /// /// Here rather than in the placeholder view because the two surfaces share one substrate and /// therefore one input type — an attributed string — and because "the text is the raw Markdown, /// character for character — no hidden transforms, no smart substitutions" (05 ▸ Edit) is a /// promise about *this* function: it sets attributes and never touches a character. static func rawText(_ body: String, context: Context) -> NSAttributedString { NSAttributedString(string: body, attributes: [ .font: monospacedFont(context.pointSize), .foregroundColor: NSColor.labelColor ]) } // MARK: Block layout state /// Where in the block structure the walk currently is: which text blocks enclose it (quotes and /// table cells nest by *appending* to this), how far it is indented, and whether a list marker /// is owed to the next paragraph. private struct Frame { let context: Context var enclosing: [NSTextBlock] = [] var indent: CGFloat = 0 /// The bullet, number or checkbox that belongs on the first line of the next block — set /// when a list item starts and consumed by whichever block opens it. var pendingMarker: NSAttributedString? } private static func append(_ blocks: [BodyBlock], into output: NSMutableAttributedString, frame: inout Frame) { for block in blocks { append(block, into: output, frame: &frame) } } private static func append(_ block: BodyBlock, into output: NSMutableAttributedString, frame: inout Frame) { let size = frame.context.pointSize switch block { case let .heading(level, inlines, _): let style = paragraphStyle(frame: frame) style.paragraphSpacingBefore = size * (level <= 2 ? 1.0 : 0.75) style.paragraphSpacing = size * 0.25 appendParagraph( inlines: inlines, base: [.font: headingFont(level: level, size: size), .foregroundColor: NSColor.labelColor], style: style, into: output, frame: &frame ) case let .paragraph(inlines, _): let style = paragraphStyle(frame: frame) style.paragraphSpacing = size * 0.5 appendParagraph(inlines: inlines, base: bodyAttributes(size), style: style, into: output, frame: &frame) case let .code(code, _, _): let style = paragraphStyle(frame: frame) style.paragraphSpacing = size * 0.5 style.firstLineHeadIndent += CardWindowMetrics.previewPadding(bodyPointSize: size) style.headIndent += CardWindowMetrics.previewPadding(bodyPointSize: size) appendLiteral( trimmingTrailingNewlines(code), attributes: [ .font: monospacedFont(size), .foregroundColor: NSColor.labelColor, .backgroundColor: NSColor.quaternarySystemFill ], style: style, into: output, frame: &frame ) case let .html(raw, _): // Verbatim, and styled as what it is: text the author typed, not a document the app // interpreted (05 ▸ Preview; 00-vision.md's no-web-tech stance). let style = paragraphStyle(frame: frame) style.paragraphSpacing = size * 0.5 appendLiteral( trimmingTrailingNewlines(raw), attributes: [ .font: monospacedFont(size), .foregroundColor: NSColor.secondaryLabelColor, .backgroundColor: NSColor.quaternarySystemFill ], style: style, into: output, frame: &frame ) case .thematicBreak: let rule = NSTextBlock() rule.setWidth(1, type: .absoluteValueType, for: .border, edge: .minY) rule.setBorderColor(.separatorColor) let style = paragraphStyle(frame: frame) style.textBlocks = frame.enclosing + [rule] style.paragraphSpacingBefore = size * 0.5 style.paragraphSpacing = size * 0.5 // A near-empty line carrying the rule: the border is the mark, the character is only // something for the layout manager to hang it on. output.append(NSAttributedString(string: "\u{00A0}\n", attributes: [ .font: NSFont.systemFont(ofSize: 1), .paragraphStyle: style ])) case let .quote(blocks, _): let bar = NSTextBlock() bar.setWidth(size * 0.2, type: .absoluteValueType, for: .border, edge: .minX) bar.setBorderColor(.tertiaryLabelColor) bar.setWidth(CardWindowMetrics.previewPadding(bodyPointSize: size), type: .absoluteValueType, for: .padding, edge: .minX) var inner = frame inner.enclosing.append(bar) append(blocks, into: output, frame: &inner) frame.pendingMarker = inner.pendingMarker case let .list(list, _): append(list, into: output, frame: &frame) case let .table(table, _): append(table, into: output, frame: &frame) } } // MARK: Lists private static func append(_ list: BodyList, into output: NSMutableAttributedString, frame: inout Frame) { let size = frame.context.pointSize for (offset, item) in list.items.enumerated() { var inner = frame inner.indent += CardWindowMetrics.previewIndent(bodyPointSize: size) inner.pendingMarker = marker(for: item, list: list, number: list.start + offset, size: size) if item.blocks.isEmpty { // An empty item still has a marker to show — a bare `- [ ]` is a checkbox the user // has not written a label for yet, and swallowing it would lose a live control. let style = paragraphStyle(frame: inner) appendParagraph(inlines: [], base: bodyAttributes(size), style: style, into: output, frame: &inner) } else { append(item.blocks, into: output, frame: &inner) } } } /// A list item's leading run: a checkbox when the item is a task, otherwise a bullet or its /// number. /// /// The checkbox carries a `.link` attribute — see `CardBodyLink` — which is what makes it the /// one clickable thing in an otherwise read-only surface. When the parse could not locate its /// source byte the box still renders, without the link: inert rather than absent, because the /// box is content the user wrote. private static func marker( for item: BodyListItem, list: BodyList, number: Int, size: CGFloat ) -> NSAttributedString { guard let task = item.task else { let text = list.isOrdered ? "\(number)." : "•" return NSAttributedString(string: text + "\t", attributes: [ .font: NSFont.systemFont(ofSize: size), .foregroundColor: NSColor.secondaryLabelColor ]) } var attributes: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: size * 1.1), .foregroundColor: task.isChecked ? NSColor.controlAccentColor : NSColor.secondaryLabelColor ] if let offset = task.markerOffset, let url = CardBodyLink.task(offset: offset, isChecked: task.isChecked) { attributes[.link] = url attributes[.cursor] = NSCursor.pointingHand } let box = NSMutableAttributedString(string: task.isChecked ? "\u{2611}" : "\u{2610}", attributes: attributes) box.append(NSAttributedString(string: "\t", attributes: [.font: NSFont.systemFont(ofSize: size)])) return box } // MARK: Tables /// A GFM table as an `NSTextTable`, whose automatic layout algorithm **is** the "columns sized /// to contents with the browser sizing rule" the design asks for — the same rule, implemented /// once by AppKit rather than approximated here with measured tab stops. /// /// This is also the reason the whole surface runs on TextKit 1 (`CardBodySurfaceView`): /// `NSTextTable` is a TextKit 1 construct, and a table drawn with tab stops would lose both /// its rules and its wrapping. private static func append(_ table: BodyTable, into output: NSMutableAttributedString, frame: inout Frame) { let columns = max(1, table.columnCount) let textTable = NSTextTable() textTable.numberOfColumns = columns textTable.layoutAlgorithm = .automaticLayoutAlgorithm textTable.collapsesBorders = true textTable.hidesEmptyCells = false var row = 0 appendRow(table.header, isHeader: true, row: &row, of: textTable, table: table, into: output, frame: &frame) for cells in table.rows { appendRow(cells, isHeader: false, row: &row, of: textTable, table: table, into: output, frame: &frame) } } private static func appendRow( _ cells: [BodyTableCell], isHeader: Bool, row: inout Int, of textTable: NSTextTable, table: BodyTable, into output: NSMutableAttributedString, frame: inout Frame ) { guard !cells.isEmpty else { return } let size = frame.context.pointSize let padding = CardWindowMetrics.previewPadding(bodyPointSize: size) var column = 0 for cell in cells where column < max(1, table.columnCount) { let span = max(1, min(cell.colspan, max(1, table.columnCount) - column)) let block = NSTextTableBlock( table: textTable, startingRow: row, rowSpan: 1, startingColumn: column, columnSpan: span ) block.setBorderColor(.separatorColor) block.setWidth(1, type: .absoluteValueType, for: .border) block.setWidth(padding, type: .absoluteValueType, for: .padding) if isHeader { block.backgroundColor = .quaternarySystemFill } let style = paragraphStyle(frame: frame) style.textBlocks = frame.enclosing + [block] style.alignment = alignment(table.alignments.indices.contains(column) ? table.alignments[column] : .unspecified) // Cells never inherit the surrounding indent: the table block already places them. style.firstLineHeadIndent = 0 style.headIndent = 0 var base = bodyAttributes(size) if isHeader { base[.font] = NSFont.systemFont(ofSize: size, weight: .semibold) } var cellFrame = frame cellFrame.pendingMarker = nil appendParagraph(inlines: cell.inlines, base: base, style: style, into: output, frame: &cellFrame) column += span } row += 1 } private static func alignment(_ alignment: BodyTable.Alignment) -> NSTextAlignment { switch alignment { case .unspecified, .leading: .natural case .center: .center case .trailing: .right } } // MARK: Paragraph assembly private static func appendParagraph( inlines: [BodyInline], base: [NSAttributedString.Key: Any], style: NSMutableParagraphStyle, into output: NSMutableAttributedString, frame: inout Frame ) { let paragraph = NSMutableAttributedString() if let marker = frame.pendingMarker { paragraph.append(marker) frame.pendingMarker = nil } append(inlines, into: paragraph, base: base, frame: frame) paragraph.append(NSAttributedString(string: "\n", attributes: base)) paragraph.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: paragraph.length)) output.append(paragraph) } /// A literal block — code, HTML — where the text's own newlines are content and no inline /// parsing ever ran over it. private static func appendLiteral( _ text: String, attributes: [NSAttributedString.Key: Any], style: NSMutableParagraphStyle, into output: NSMutableAttributedString, frame: inout Frame ) { let paragraph = NSMutableAttributedString() if let marker = frame.pendingMarker { paragraph.append(marker) frame.pendingMarker = nil } paragraph.append(NSAttributedString(string: text + "\n", attributes: attributes)) paragraph.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: paragraph.length)) output.append(paragraph) } private static func paragraphStyle(frame: Frame) -> NSMutableParagraphStyle { let style = NSMutableParagraphStyle() style.textBlocks = frame.enclosing style.firstLineHeadIndent = frame.indent style.headIndent = frame.indent style.lineSpacing = frame.context.pointSize * 0.15 // One tab stop, where a list item's text begins — which is what turns "marker, tab, text" // into a hanging indent rather than a ragged one. style.tabStops = [NSTextTab(textAlignment: .left, location: frame.indent, options: [:])] style.defaultTabInterval = CardWindowMetrics.previewIndent(bodyPointSize: frame.context.pointSize) return style } // MARK: Inlines /// The inline traits carried down the recursion — bold and italic compose, so they are state /// rather than a font chosen at each node. private struct InlineTraits { var isBold = false var isItalic = false var isStruck = false } private static func append( _ inlines: [BodyInline], into output: NSMutableAttributedString, base: [NSAttributedString.Key: Any], frame: Frame, traits: InlineTraits = InlineTraits() ) { for inline in inlines { append(inline, into: output, base: base, frame: frame, traits: traits) } } private static func append( _ inline: BodyInline, into output: NSMutableAttributedString, base: [NSAttributedString.Key: Any], frame: Frame, traits: InlineTraits ) { let size = frame.context.pointSize switch inline { case let .text(text): output.append(NSAttributedString(string: text, attributes: attributes(base, traits: traits))) case let .emphasis(children): var inner = traits inner.isItalic = true append(children, into: output, base: base, frame: frame, traits: inner) case let .strong(children): var inner = traits inner.isBold = true append(children, into: output, base: base, frame: frame, traits: inner) case let .strikethrough(children): var inner = traits inner.isStruck = true append(children, into: output, base: base, frame: frame, traits: inner) case let .code(code): var attributes = attributes(base, traits: traits) attributes[.font] = monospacedFont(size) attributes[.backgroundColor] = NSColor.quaternarySystemFill output.append(NSAttributedString(string: code, attributes: attributes)) case let .html(raw): // Same rule as the block form, one line down: `
` is four characters and a `>`. var attributes = attributes(base, traits: traits) attributes[.font] = monospacedFont(size) attributes[.foregroundColor] = NSColor.secondaryLabelColor attributes[.backgroundColor] = NSColor.quaternarySystemFill output.append(NSAttributedString(string: raw, attributes: attributes)) case .lineBreak, .softBreak: // A soft break is a newline in the source and a space to the reader; a hard break is a // line of its own. `NSAttributedString` has no soft-wrap character, so the difference // is exactly this. let text = if case .lineBreak = inline { "\u{2028}" } else { " " } output.append(NSAttributedString(string: text, attributes: attributes(base, traits: traits))) case let .link(target, children): var attributes = attributes(base, traits: traits) if let url = target.resolve(inCardFolder: frame.context.cardFolder) { attributes[.link] = url attributes[.foregroundColor] = NSColor.linkColor attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue } let start = output.length append(children, into: output, base: base, frame: frame, traits: traits) if output.length == start { output.append(NSAttributedString(string: target.text, attributes: attributes)) } else { output.addAttributes(attributes, range: NSRange(location: start, length: output.length - start)) } case let .image(image): output.append(rendered(image, frame: frame, base: base, traits: traits)) } } /// An image: **loaded from the card's folder, or a chip that says what was not loaded.** /// /// The fork is `BodyTarget`'s, made in the model; what happens here is only the consequence. /// Nothing in this function opens a socket — a `.absolute` target is never handed to a loader /// at all, which is how "Preview does no networking" is enforced rather than merely intended /// (05 ▸ Preview). private static func rendered( _ image: BodyImage, frame: Frame, base: [NSAttributedString.Key: Any], traits: InlineTraits ) -> NSAttributedString { let size = frame.context.pointSize if case .relative = image.target, let url = image.target.resolve(inCardFolder: frame.context.cardFolder), let loaded = NSImage(contentsOf: url) { let attachment = NSTextAttachment() attachment.image = loaded let cap = CardWindowMetrics.previewImageMaximumWidth(bodyPointSize: size) let scale = loaded.size.width > cap && loaded.size.width > 0 ? cap / loaded.size.width : 1 attachment.bounds = CGRect( x: 0, y: 0, width: (loaded.size.width * scale).rounded(), height: (loaded.size.height * scale).rounded() ) let string = NSMutableAttributedString(attachment: attachment) // The alt text as the tool tip: it is the one place an inline attachment can say what // it is, and it is also what the text view reads out when nothing else describes it. string.addAttribute( .toolTip, value: image.alt.isEmpty ? image.target.text : image.alt, range: NSRange(location: 0, length: string.length) ) return string } // The quiet placeholder chip: alt text where there is any, the URL where there is not, so // the reader always learns *what* is not being shown. let label = image.alt.isEmpty ? image.target.text : image.alt var attributes = attributes(base, traits: traits) attributes[.font] = NSFont.systemFont(ofSize: size * 0.9) attributes[.foregroundColor] = NSColor.secondaryLabelColor attributes[.backgroundColor] = NSColor.quaternarySystemFill return NSAttributedString(string: " \u{1F5BC}\u{FE0E} \(label) ", attributes: attributes) } // MARK: Fonts private static func attributes( _ base: [NSAttributedString.Key: Any], traits: InlineTraits ) -> [NSAttributedString.Key: Any] { var attributes = base if let font = base[.font] as? NSFont { attributes[.font] = styled(font, traits: traits) } if traits.isStruck { attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } return attributes } private static func styled(_ font: NSFont, traits: InlineTraits) -> NSFont { var mask: NSFontTraitMask = [] if traits.isBold { mask.insert(.boldFontMask) } if traits.isItalic { mask.insert(.italicFontMask) } guard !mask.isEmpty else { return font } return NSFontManager.shared.convert(font, toHaveTrait: mask) } private static func bodyAttributes(_ size: CGFloat) -> [NSAttributedString.Key: Any] { [.font: NSFont.systemFont(ofSize: size), .foregroundColor: NSColor.labelColor] } /// The heading ladder, in multiples of the body size so it scales with everything else. Levels /// past four stop growing and stay merely emphasized, which is what they are. private static func headingFont(level: Int, size: CGFloat) -> NSFont { let scale: CGFloat = switch level { case 1: 1.8 case 2: 1.5 case 3: 1.25 case 4: 1.1 default: 1.0 } return NSFont.systemFont(ofSize: (size * scale).rounded(), weight: level <= 2 ? .bold : .semibold) } private static func monospacedFont(_ size: CGFloat) -> NSFont { NSFont.monospacedSystemFont(ofSize: (size * 0.95).rounded(), weight: .regular) } private static func trimmingTrailingNewlines(_ text: String) -> String { var text = text while text.hasSuffix("\n") || text.hasSuffix("\r") { text.removeLast() } return text } }