The card body's resting state: swift-markdown (pinned 0.8.0, smart typography off — Preview renders the bytes on disk) parsed into a pure BodyMarkup model with UTF-8 source offsets, rendered on one hosted TextKit 1 NSTextView — chosen because find-in-text is NSTextFinder, checkbox clicks reuse AppKit character hit-testing, links are .link attributes, and NSTextTable's automatic layout is exactly the columns-sized-to-contents rule. The GFM subset renders per 05; HTML stays verbatim code-styled text; relative images resolve against the card folder while remote URLs are never fetched, drawing a quiet chip instead. Task checkboxes are live: a click flips exactly one byte through a fresh-read, refuse-uneditable, stamp, atomic-replace write — the app's only offset-addressed write, so a moved target refuses as staleTarget and what the user saw decides the direction, netting one toggle on a double-click. Empty bodies open in Edit per CardBodyMode's opening rule, applied once; the Edit surface itself stays an honest read-only stub until its card. FindCommand prefers the card body's find over board search when a card window is focused. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
571 lines
25 KiB
Swift
571 lines
25 KiB
Swift
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:<byte offset>/<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: `<br>` 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
|
|
}
|
|
}
|