import AppKit import SwiftUI // MARK: - A rendered comment body /// One comment's Markdown, rendered — **the card-body subset, through the card body's own renderer** /// (05-card-window.md ▸ The comments column: "the rendered Markdown body (the card-body subset)"). /// /// ### Why the same renderer and not a `Text(AttributedString(markdown:))` /// /// Because "the card-body subset" is a promise about *this* app's subset: fenced code, nested quotes, /// GFM tables with per-column alignment, task markers, images resolved against the card's own folder, /// HTML shown verbatim as literal code-styled text. `BodyMarkupRenderer` is where every one of those /// is decided, and a second renderer here would be a second answer to each — a comment quoting a code /// block would look like a different app from the card that carries it. /// /// ### Why it is not `CardBodySurface` /// /// That surface is a hosted **scroll** view, because ⌘F's find bar lives in one and because a card /// body is a document. A thread is a *list* of bodies inside one scroller, and a scroll view per row /// would be a scroll view that fights its parent — the same reasoning that put the card's title above /// the body's scroller rather than inside it. So this is the same TextKit 1 stack with the scroller /// taken off and an intrinsic height instead: it lays out at the width it is proposed and reports /// exactly the height its text needs. /// /// ### It is a find *result* surface, not a find client /// /// 05 scopes ⌘F over the pane to "the whole rendered thread", which no per-row `NSTextFinder` could /// span (`CommentThreadFind` explains the mechanism chosen instead). What this view owes that /// mechanism is two things: the storage it draws must be the same text the search ran over — it is, /// because both come from `BodyMarkupRenderer` over the same body — and it must be able to draw a /// highlight over a range without touching that storage. `NSLayoutManager`'s **temporary attributes** /// are exactly that: a display-only overlay, discarded and reapplied freely, which cannot end up in /// anything the user copies out. struct CommentBodyView: NSViewRepresentable { let body: String /// The **card**'s folder, not the comment's — relative images and links in a comment resolve the /// same way a card body's do, which is what makes `![](attachments/shot.png)` mean one thing in /// this window (05 ▸ Preview). let cardFolder: URL? /// This comment's find hits, in the rendered text's coordinates (`CommentThreadFind.matches(in:)`). var highlights: [NSRange] = [] /// Which of them is the current one, if it is in this comment — drawn in the stronger colour, the /// find bar's own convention. var currentHighlight: NSRange? /// The pane's focus register — this surface reports itself as a *reading* surface, which is what /// makes ⌘F over a clicked-into comment mean the thread (`CardComments.paneFocus`). var focus: CardComments? /// The height a measurement pass lays out into — tall enough that no comment reaches it, finite /// so the arithmetic stays well-defined. private static let layoutCeiling: CGFloat = 100_000 func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> CommentBodyTextView { // TextKit 1, explicitly, for `CardBodySurface`'s reason: `NSTextTable` — the browser sizing // rule GFM tables are laid out by — does not lay out in TextKit 2. let storage = NSTextStorage() let layoutManager = NSLayoutManager() storage.addLayoutManager(layoutManager) let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) container.widthTracksTextView = true container.lineFragmentPadding = 0 layoutManager.addTextContainer(container) let textView = CommentBodyTextView(frame: .zero, textContainer: container) textView.delegate = context.coordinator textView.isEditable = false // Selectable and copyable, the whole thread — Preview's own posture, and the reason a comment // can be quoted without a mode flip. textView.isSelectable = true textView.isRichText = true textView.drawsBackground = false textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.textContainerInset = .zero textView.linkTextAttributes = [.cursor: NSCursor.pointingHand] textView.displaysLinkToolTips = true // The pane's focus register, so ⌘F over a comment the user has clicked into means the thread // (`CardComments.paneFocus`). A weak capture is not needed: the handle outlives the window. let focus = focus textView.onFocusChange = { gained in if gained { focus?.focusEntered(.thread) } else { focus?.focusLeft(.thread) } } return textView } func updateNSView(_ textView: CommentBodyTextView, context: Context) { let key = Coordinator.RenderKey( body: body, cardFolder: cardFolder, pointSize: CardWindowMetrics.bodyPointSize ) if context.coordinator.rendered != key { context.coordinator.rendered = key textView.textStorage?.setAttributedString(BodyMarkupRenderer.attributedString( for: BodyMarkup.parse(body), context: BodyMarkupRenderer.Context(pointSize: key.pointSize, cardFolder: cardFolder) )) // The overlay describes ranges in text that has just been replaced, so it is reapplied // rather than assumed to have survived. context.coordinator.highlighted = nil } let highlightKey = Coordinator.HighlightKey(ranges: highlights, current: currentHighlight) guard context.coordinator.highlighted != highlightKey else { return } context.coordinator.highlighted = highlightKey Self.applyHighlights(highlightKey, in: textView) } /// Draws the find's hits — **temporary attributes only**, so nothing here can reach the text /// storage a reader copies out of, and clearing is a single call rather than a diff. /// /// The two colours are the platform's own find vocabulary: every hit in the secondary selection /// colour, the current one in the *find* highlight colour, which is what an `NSTextFinder` bar /// draws and therefore what a user of this app's other finds has already learned. private static func applyHighlights(_ key: Coordinator.HighlightKey, in textView: CommentBodyTextView) { guard let layoutManager = textView.layoutManager, let storage = textView.textStorage else { return } let whole = NSRange(location: 0, length: storage.length) layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: whole) for range in key.ranges where NSMaxRange(range) <= storage.length { layoutManager.addTemporaryAttributes( [.backgroundColor: NSColor.unemphasizedSelectedTextBackgroundColor], forCharacterRange: range ) } guard let current = key.current, NSMaxRange(current) <= storage.length else { return } layoutManager.addTemporaryAttributes( [.backgroundColor: NSColor.findHighlightColor], forCharacterRange: current ) } /// **The intrinsic height** — the whole reason this is not a scroll view. /// /// The container is laid out at the proposed width and asked what it used. `ensureLayout` is not /// optional: `usedRect` is only meaningful once the glyphs have been laid, and an unlaid container /// answers a zero-height rect, which would collapse every comment in the thread to nothing. func sizeThatFits(_ proposal: ProposedViewSize, nsView: CommentBodyTextView, context: Context) -> CGSize? { guard let container = nsView.textContainer, let layoutManager = nsView.layoutManager else { return nil } guard let width = proposal.width, width > 0, width.isFinite else { return nil } // A large finite height rather than `.greatestFiniteMagnitude`: the container tracks the // view's width, so the frame is how the width is proposed at all, and an infinite frame // height propagates into the layout arithmetic as a value nothing can subtract from. nsView.frame = NSRect(x: 0, y: 0, width: width, height: Self.layoutCeiling) layoutManager.ensureLayout(for: container) return CGSize(width: width, height: layoutManager.usedRect(for: container).height.rounded(.up)) } // MARK: - Coordinator @MainActor final class Coordinator: NSObject, NSTextViewDelegate { struct RenderKey: Equatable { let body: String let cardFolder: URL? let pointSize: CGFloat } /// What the find overlay currently draws — kept for `RenderKey`'s reason one layer down: the /// update runs on every unrelated state change in the window, and reapplying an identical /// overlay would redraw every visible comment on each of them. struct HighlightKey: Equatable { let ranges: [NSRange] let current: NSRange? } var rendered: RenderKey? var highlighted: HighlightKey? /// Links behave exactly as they do in a card body: external URLs go to the browser, relative /// ones — already resolved to file URLs by the renderer — go to their default app. /// /// **A task marker in a comment is inert.** 05 makes live checkboxes a rule about *Preview*, /// the card body's one interactive exception, and gives a comment no toggle write path at all /// — the way to change a comment is Edit it. Swallowing the click (rather than letting it fall /// through to `NSWorkspace.open`, which would try to open a `kanban-task:` URL) is what keeps /// the checkbox drawn-but-dead rather than drawn-and-broken. func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { guard let url = Self.url(from: link) else { return false } guard CardBodyLink.parseTask(url) == nil else { return true } NSWorkspace.shared.open(url) return true } private static func url(from link: Any) -> URL? { switch link { case let url as URL: url case let string as String: URL(string: string) default: nil } } } } // MARK: - The row's text view /// A rendered comment's text view, subclassed for one thing: **it says when it has the keyboard**. /// /// `FocusReportingSearchField`'s shape, and for its reason — ⌘F's route is a *focus* question /// (05-card-window.md ▸ Preview: the find covers "the focused surface"), and the only responder that /// can answer it is the one taking and losing first responder. Both halves are overridden here rather /// than one being inferred from the other, because a reading surface has no editing session and /// therefore no `textDidEndEditing` to hang the loss on. final class CommentBodyTextView: NSTextView { var onFocusChange: ((Bool) -> Void)? override func becomeFirstResponder() -> Bool { let accepted = super.becomeFirstResponder() if accepted { onFocusChange?(true) } return accepted } override func resignFirstResponder() -> Bool { let resigned = super.resignFirstResponder() if resigned { onFocusChange?(false) } return resigned } }