A no-Xcode AppKit app (swiftc + build.sh): - Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find, live Markdown preview (split mode), selection-aware Markdown toolbar. - Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape, US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel), freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich status bar. The Editor follows the selected page; layout persists to a foo.txt.board sidecar; the .txt stays pure plaintext. - scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain). - HTML scraping pipeline: custom selector engine + Discogs profile, ingests to Postgres (psql) into a marketplace_snapshots schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
6.2 KiB
Swift
149 lines
6.2 KiB
Swift
import AppKit
|
|
|
|
/// A line-number gutter for an NSTextView, themed to match the editor.
|
|
/// Highlights the line containing the insertion point.
|
|
final class LineNumberRulerView: NSRulerView {
|
|
private weak var textView: NSTextView?
|
|
private var theme: EditorTheme = .system
|
|
|
|
init(textView: NSTextView) {
|
|
self.textView = textView
|
|
super.init(scrollView: textView.enclosingScrollView, orientation: .verticalRuler)
|
|
clientView = textView
|
|
ruleThickness = 44
|
|
|
|
let center = NotificationCenter.default
|
|
center.addObserver(self, selector: #selector(needsRedraw),
|
|
name: NSText.didChangeNotification, object: textView)
|
|
center.addObserver(self, selector: #selector(needsRedraw),
|
|
name: NSTextView.didChangeSelectionNotification, object: textView)
|
|
if let clip = textView.enclosingScrollView?.contentView {
|
|
clip.postsBoundsChangedNotifications = true
|
|
center.addObserver(self, selector: #selector(needsRedraw),
|
|
name: NSView.boundsDidChangeNotification, object: clip)
|
|
}
|
|
}
|
|
|
|
required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
deinit { NotificationCenter.default.removeObserver(self) }
|
|
|
|
func apply(theme: EditorTheme) {
|
|
self.theme = theme
|
|
needsDisplay = true
|
|
}
|
|
|
|
/// Point the gutter at a different text view (used when the editor switches
|
|
/// which board page it is showing).
|
|
func retarget(to tv: NSTextView) {
|
|
NotificationCenter.default.removeObserver(self)
|
|
textView = tv
|
|
clientView = tv
|
|
let center = NotificationCenter.default
|
|
center.addObserver(self, selector: #selector(needsRedraw),
|
|
name: NSText.didChangeNotification, object: tv)
|
|
center.addObserver(self, selector: #selector(needsRedraw),
|
|
name: NSTextView.didChangeSelectionNotification, object: tv)
|
|
if let clip = tv.enclosingScrollView?.contentView {
|
|
clip.postsBoundsChangedNotifications = true
|
|
center.addObserver(self, selector: #selector(needsRedraw),
|
|
name: NSView.boundsDidChangeNotification, object: clip)
|
|
}
|
|
needsDisplay = true
|
|
}
|
|
|
|
@objc private func needsRedraw() { needsDisplay = true }
|
|
|
|
/// Widen the gutter as the line count grows so 4- and 5-digit files fit.
|
|
func updateThickness() {
|
|
guard let lineCount = textView?.string.reduce(into: 1, { c, ch in if ch == "\n" { c += 1 } }) else { return }
|
|
let digits = max(2, String(lineCount).count)
|
|
let font = gutterFont
|
|
let sample = String(repeating: "8", count: digits)
|
|
let width = (sample as NSString).size(withAttributes: [.font: font]).width
|
|
let thickness = ceil(width) + 16
|
|
if abs(thickness - ruleThickness) > 0.5 { ruleThickness = thickness }
|
|
}
|
|
|
|
private var gutterFont: NSFont {
|
|
let base = (textView?.font?.pointSize ?? 13) - 2
|
|
return NSFont.monospacedDigitSystemFont(ofSize: max(9, base), weight: .regular)
|
|
}
|
|
|
|
override func drawHashMarksAndLabels(in rect: NSRect) {
|
|
guard let textView,
|
|
let layoutManager = textView.layoutManager,
|
|
let container = textView.textContainer,
|
|
let scrollView = scrollView else { return }
|
|
|
|
theme.gutterBackground.setFill()
|
|
bounds.fill()
|
|
|
|
// Subtle separator line on the gutter's trailing edge.
|
|
theme.gutterText.withAlphaComponent(0.25).setStroke()
|
|
let sep = NSBezierPath()
|
|
sep.move(to: NSPoint(x: bounds.maxX - 0.5, y: bounds.minY))
|
|
sep.line(to: NSPoint(x: bounds.maxX - 0.5, y: bounds.maxY))
|
|
sep.lineWidth = 1
|
|
sep.stroke()
|
|
|
|
let content = scrollView.contentView
|
|
let visibleRect = content.bounds
|
|
let inset = textView.textContainerInset
|
|
let nsString = textView.string as NSString
|
|
|
|
let font = gutterFont
|
|
let normalAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.gutterText]
|
|
let activeAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.accent]
|
|
|
|
// Which line holds the insertion point?
|
|
let selectedLine = currentLineNumber(in: nsString, selection: textView.selectedRange())
|
|
|
|
let glyphRange = layoutManager.glyphRange(forBoundingRect: visibleRect, in: container)
|
|
let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
|
|
|
|
var lineNumber = lineCount(in: nsString, upTo: charRange.location)
|
|
var index = charRange.location
|
|
|
|
while index <= NSMaxRange(charRange) {
|
|
let lineRange = nsString.lineRange(for: NSRange(location: index, length: 0))
|
|
let glyphLineRange = layoutManager.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil)
|
|
var lineRect = layoutManager.boundingRect(forGlyphRange: glyphLineRange, in: container)
|
|
lineRect.origin.y += inset.height
|
|
|
|
// Convert text Y into ruler Y.
|
|
let y = lineRect.minY - visibleRect.minY
|
|
let isActive = lineNumber == selectedLine
|
|
if isActive {
|
|
theme.currentLine.setFill()
|
|
NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill()
|
|
}
|
|
let label = "\(lineNumber)" as NSString
|
|
let attrs = isActive ? activeAttrs : normalAttrs
|
|
let size = label.size(withAttributes: attrs)
|
|
let drawY = y + (lineRect.height - size.height) / 2
|
|
label.draw(at: NSPoint(x: bounds.width - size.width - 8, y: drawY), withAttributes: attrs)
|
|
|
|
if NSMaxRange(lineRange) <= index { break }
|
|
index = NSMaxRange(lineRange)
|
|
lineNumber += 1
|
|
if index > nsString.length { break }
|
|
}
|
|
}
|
|
|
|
private func lineCount(in s: NSString, upTo location: Int) -> Int {
|
|
var count = 1
|
|
var i = 0
|
|
let end = min(location, s.length)
|
|
while i < end {
|
|
if s.character(at: i) == 10 { count += 1 }
|
|
i += 1
|
|
}
|
|
return count
|
|
}
|
|
|
|
private func currentLineNumber(in s: NSString, selection: NSRange) -> Int {
|
|
lineCount(in: s, upTo: selection.location)
|
|
}
|
|
}
|