The editing surface: the same hosted TextKit-1 text view gains an editable branch with a per-keystroke line-scanner highlighter — chosen over a parser re-parse because a mid-typing buffer is usually invalid Markdown and 05 wants the delimiters themselves dimmed; apply only sets attributes, so presentation-never-transforms is structural. Saves ride a ~700ms injectable debounce through BoardWriter.writeBody — toggleTaskMarker's idiom widened to the body span, frontmatter bytes untouched, refusing to write when disk already holds that body, which enforces all three gates (untouched, reverted, echo) at the layer that owns the bytes with one isDirty predicate above it. Mode grammar lands whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape returns, and every flip flushes first; window close flushes through the existing retry/save-copy/discard modal, and the dismissal flush deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always follows the snapshot, the buffer only when clean, both surfaces render the buffer. Undo is the editor's own session-scoped NSUndoManager; endEditSession names the pro-m1 one-commit-per-session boundary. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
563 lines
24 KiB
Swift
563 lines
24 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 Edit surface's raw, monospaced rendering used to live here as `rawText`, while Edit was a
|
|
// read-only placeholder sharing this file's substrate. It is now `MarkdownHighlighter`'s —
|
|
// base attributes plus a span pass — and the promise it carried travelled with it: the
|
|
// highlighter emits ranges, never a string, so "the text is the raw Markdown, character for
|
|
// character" (05 ▸ Edit) is structural rather than a convention.
|
|
|
|
// 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
|
|
}
|
|
}
|