textplus/Sources/LineNumberRuler.swift
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
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>
2026-06-14 16:08:59 +10:00

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)
}
}