textplus/Sources/WorkspaceController.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

565 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AppKit
/// Owns a document window's chrome: the toolbar, the editor / split / canvas
/// mode switching, the Markdown preview, and the bottom status bar. One per
/// document window; created in Document.makeWindowControllers.
final class WorkspaceController: NSObject, NSToolbarDelegate, NSTextViewDelegate {
enum Mode: Int { case editor, split, board }
weak var document: Document?
let containerView = NSView()
let editorScrollView: NSScrollView
let textView: NSTextView // the document's main page (== the .txt)
private var editorTV: NSTextView // the text view currently shown in the editor
private weak var activeEditorCard: CardView? // which board page the editor shows
private let ruler: LineNumberRulerView
private let splitView = NSSplitView()
private let previewScroll = NSScrollView()
private let previewTextView = NSTextView()
let boardContainer = BoardContainerView(frame: .zero)
private let statusBar = NSView()
private let leftStatus = NSTextField(labelWithString: "")
private let centerStatus = NSTextField(labelWithString: "")
private let rightStatus = NSTextField(labelWithString: "")
private var modeSegment: NSSegmentedControl?
private(set) var mode: Mode = .editor
init(document: Document, editorScrollView: NSScrollView, textView: NSTextView) {
self.document = document
self.editorScrollView = editorScrollView
self.textView = textView
self.editorTV = textView
self.ruler = LineNumberRulerView(textView: textView)
super.init()
buildLayout()
hookBoard()
NotificationCenter.default.addObserver(self, selector: #selector(appearanceChanged),
name: AppSettings.didChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(textChanged),
name: NSText.didChangeNotification, object: textView)
NotificationCenter.default.addObserver(self, selector: #selector(selectionChanged),
name: NSTextView.didChangeSelectionNotification, object: textView)
applyAppearance()
recomputeMetrics()
refreshStatus()
}
deinit { NotificationCenter.default.removeObserver(self) }
// MARK: - Layout
private func buildLayout() {
editorScrollView.hasVerticalRuler = true
editorScrollView.rulersVisible = AppSettings.lineNumbers
editorScrollView.verticalRulerView = ruler
// Preview pane
previewScroll.hasVerticalScroller = true
previewScroll.borderType = .noBorder
previewScroll.documentView = previewTextView
previewTextView.isEditable = false
previewTextView.isSelectable = true
previewTextView.textContainerInset = NSSize(width: 16, height: 14)
previewTextView.autoresizingMask = [.width]
previewTextView.minSize = NSSize(width: 0, height: 0)
previewTextView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
previewTextView.isVerticallyResizable = true
previewTextView.isHorizontallyResizable = false
previewTextView.textContainer?.widthTracksTextView = true
splitView.isVertical = true
splitView.dividerStyle = .thin
splitView.translatesAutoresizingMaskIntoConstraints = false
splitView.addArrangedSubview(editorScrollView)
boardContainer.translatesAutoresizingMaskIntoConstraints = false
boardContainer.isHidden = true
statusBar.translatesAutoresizingMaskIntoConstraints = false
statusBar.wantsLayer = true
buildStatusBar()
containerView.addSubview(splitView)
containerView.addSubview(boardContainer)
containerView.addSubview(statusBar)
NSLayoutConstraint.activate([
statusBar.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
statusBar.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
statusBar.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
statusBar.heightAnchor.constraint(equalToConstant: 24),
splitView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
splitView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
splitView.topAnchor.constraint(equalTo: containerView.topAnchor),
splitView.bottomAnchor.constraint(equalTo: statusBar.topAnchor),
boardContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
boardContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
boardContainer.topAnchor.constraint(equalTo: containerView.topAnchor),
boardContainer.bottomAnchor.constraint(equalTo: statusBar.topAnchor),
])
}
private func buildStatusBar() {
for label in [leftStatus, centerStatus, rightStatus] {
label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
label.translatesAutoresizingMaskIntoConstraints = false
statusBar.addSubview(label)
}
centerStatus.alignment = .center
rightStatus.alignment = .right
NSLayoutConstraint.activate([
leftStatus.leadingAnchor.constraint(equalTo: statusBar.leadingAnchor, constant: 12),
leftStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor),
centerStatus.centerXAnchor.constraint(equalTo: statusBar.centerXAnchor),
centerStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor),
rightStatus.trailingAnchor.constraint(equalTo: statusBar.trailingAnchor, constant: -12),
rightStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor),
])
}
private func hookBoard() {
boardContainer.document = document
boardContainer.onStatus = { [weak self] msg in self?.centerStatus.stringValue = msg }
boardContainer.onSendToTailnet = { [weak self] data in self?.document?.sendSketchToTailnet(pngData: data) }
boardContainer.boardView.onChange = { [weak self] in
self?.document?.updateChangeCount(.changeDone); self?.refreshStatus()
}
boardContainer.onStatusChange = { [weak self] in self?.refreshStatus() }
}
// MARK: - Mode switching
func setMode(_ newMode: Mode) {
let leavingBoard = (mode == .board && newMode != .board)
mode = newMode
modeSegment?.selectedSegment = newMode.rawValue
switch newMode {
case .editor:
if leavingBoard { showPageInEditor() }
boardContainer.isHidden = true
splitView.isHidden = false
if previewScroll.superview != nil {
previewScroll.removeFromSuperview()
}
applyWrap(AppSettings.softWrap)
containerView.window?.makeFirstResponder(editorTV)
case .split:
if leavingBoard { showPageInEditor() }
boardContainer.isHidden = true
splitView.isHidden = false
if previewScroll.superview == nil {
// Editor stays at index 0; append preview. (Editor was never removed.)
splitView.addArrangedSubview(previewScroll)
}
applyWrap(AppSettings.softWrap)
refreshPreview()
DispatchQueue.main.async {
let w = self.splitView.bounds.width
if w > 0 { self.splitView.setPosition(w * 0.52, ofDividerAt: 0) }
}
containerView.window?.makeFirstResponder(editorTV)
case .board:
returnPageToBoard()
splitView.isHidden = true
boardContainer.isHidden = false
DispatchQueue.main.async { self.boardContainer.boardView.centerOnMain() }
containerView.window?.makeFirstResponder(boardContainer.boardView)
}
refreshStatus()
}
// MARK: - Editor page binding (the editor follows the selected board page)
/// Called once after the board loads: the document text view (in the editor)
/// belongs to the main page card.
func bindInitialEditorPage() {
boardContainer.boardView.assignMainTextView(textView)
activeEditorCard = boardContainer.boardView.mainCardView
}
/// Leaving the board: drop the active page's text view back into its card.
private func returnPageToBoard() {
boardContainer.boardView.unfocusCard(commit: true)
activeEditorCard?.mountTextView()
editorScrollView.rulersVisible = false
}
/// Entering editor/split from the board: host the selected page's text view
/// (or the main page) in the editor and re-point all editor chrome to it.
private func showPageInEditor() {
let bv = boardContainer.boardView
bv.unfocusCard(commit: true)
guard let card = bv.editorPageCandidate(), let tv = card.unmountTextView() else { return }
activeEditorCard = card
tv.isEditable = true // idle board cards become editable in the editor
tv.isSelectable = true
editorScrollView.documentView = tv
editorScrollView.rulersVisible = AppSettings.lineNumbers
retargetEditor(to: tv)
}
/// Move the editor's observers, ruler, wrap, theme and metrics onto `tv`.
private func retargetEditor(to tv: NSTextView) {
if editorTV !== tv {
let c = NotificationCenter.default
c.removeObserver(self, name: NSText.didChangeNotification, object: editorTV)
c.removeObserver(self, name: NSTextView.didChangeSelectionNotification, object: editorTV)
editorTV = tv
c.addObserver(self, selector: #selector(textChanged), name: NSText.didChangeNotification, object: tv)
c.addObserver(self, selector: #selector(selectionChanged), name: NSTextView.didChangeSelectionNotification, object: tv)
}
themeEditorTextView()
ruler.retarget(to: tv)
ruler.updateThickness()
applyWrap(AppSettings.softWrap)
recomputeMetrics()
refreshPreview()
}
@objc private func cycleMode(_ sender: NSSegmentedControl) {
setMode(Mode(rawValue: sender.selectedSegment) ?? .editor)
}
// MARK: - Appearance
@objc private func appearanceChanged() { applyAppearance() }
/// Apply the current theme/font to whichever text view the editor is showing.
private func themeEditorTextView() {
let theme = AppSettings.theme
let font = AppSettings.editorFont
let tv = editorTV
tv.font = font
tv.backgroundColor = theme.background
tv.drawsBackground = false // the card / editor draws the background
tv.textColor = theme.foreground
tv.insertionPointColor = theme.caret
tv.selectedTextAttributes = [.backgroundColor: theme.selection]
tv.typingAttributes = [.font: font, .foregroundColor: theme.foreground]
let full = NSRange(location: 0, length: (tv.string as NSString).length)
tv.textStorage?.addAttributes([.font: font, .foregroundColor: theme.foreground], range: full)
}
func applyAppearance() {
let theme = AppSettings.theme
let font = AppSettings.editorFont
containerView.window?.appearance = theme.appearance
themeEditorTextView()
// Keep on-board (idle) text cards themed too, so they read correctly
// whether shown on the board or lifted into the editor.
boardContainer.boardView.applyTextTheme(font: font, fg: theme.foreground, caret: theme.caret)
editorScrollView.backgroundColor = theme.background
editorScrollView.drawsBackground = true
ruler.apply(theme: theme)
// Editor-only affordances: skip while the text view is reparented into
// the board (rulersVisible / applyWrap both read editorScrollView state).
if mode != .board {
editorScrollView.rulersVisible = AppSettings.lineNumbers
ruler.updateThickness()
applyWrap(AppSettings.softWrap)
}
previewScroll.backgroundColor = theme.background
previewTextView.backgroundColor = theme.background
previewTextView.drawsBackground = true
refreshPreview()
boardContainer.apply(theme: theme)
statusBar.layer?.backgroundColor = theme.gutterBackground.cgColor
for label in [leftStatus, centerStatus, rightStatus] { label.textColor = theme.secondaryText }
}
private func applyWrap(_ wrap: Bool) {
let tv = editorTV
guard let container = tv.textContainer else { return }
if wrap {
container.widthTracksTextView = true
container.size = NSSize(width: editorScrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude)
tv.isHorizontallyResizable = false
tv.autoresizingMask = [.width]
editorScrollView.hasHorizontalScroller = false
} else {
container.widthTracksTextView = false
container.size = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
tv.isHorizontallyResizable = true
tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
tv.autoresizingMask = [.width, .height]
editorScrollView.hasHorizontalScroller = true
}
tv.needsDisplay = true
}
// MARK: - Preview + status
@objc func textChanged() {
if mode == .split { refreshPreview() }
ruler.updateThickness()
recomputeMetrics()
refreshStatus()
}
@objc func selectionChanged() { refreshStatus() }
// Word/char/line counts are cached so the board's pan/zoom status updates
// don't re-scan a large pasted document on every notification.
private var metrics: (words: Int, chars: Int, lines: Int) = (0, 0, 1)
private func recomputeMetrics() {
let s = editorTV.string
metrics.chars = (s as NSString).length
metrics.words = s.split { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" }.count
metrics.lines = s.isEmpty ? 1 : s.reduce(into: 1) { c, ch in if ch == "\n" { c += 1 } }
}
/// Called after the document sets text programmatically (load / openResults).
func refreshTextMetrics() { recomputeMetrics(); refreshStatus() }
private func refreshPreview() {
guard mode == .split else { return }
let theme = AppSettings.theme
let attributed = MarkdownRenderer.render(editorTV.string, theme: theme, baseSize: AppSettings.fontSize + 1)
previewTextView.textStorage?.setAttributedString(attributed)
previewTextView.textColor = theme.foreground
}
private func refreshStatus() {
let words = metrics.words, chars = metrics.chars, lines = metrics.lines
let theme = AppSettings.theme
if mode == .board {
let bv = boardContainer.boardView
if let card = bv.selectedCardView {
let f = card.frame
leftStatus.stringValue = "\(cardKindLabel(card.kind)) · \(Int(f.width.rounded()))×\(Int(f.height.rounded())) pt"
} else {
leftStatus.stringValue = "\(boardContainer.currentTool.name) tool · "
+ "\(bv.mainPageSize.shortLabel) \(bv.mainOrientation == .landscape ? "Landscape" : "Portrait")"
}
centerStatus.stringValue = "Board · \(theme.name) · \(boardContainer.zoomPercent)%"
let n = bv.cards.count
rightStatus.stringValue = "\(n) card\(n == 1 ? "" : "s") · \(words) words · \(chars) chars"
return
}
// editor / split line:col needs the live string
let ns = editorTV.string as NSString
let sel = editorTV.selectedRange()
var line = 1, col = 1, i = 0
while i < sel.location && i < ns.length {
if ns.character(at: i) == 10 { line += 1; col = 1 } else { col += 1 }
i += 1
}
var left = "Ln \(line), Col \(col)"
if sel.length > 0 { left += " · \(sel.length) selected" }
leftStatus.stringValue = left
let modeName = mode == .split ? "Split" : "Editor"
// Make it obvious when the editor is showing a board note page, not the .txt.
let pageNote = (activeEditorCard?.kind == .text) ? "Board note · " : ""
centerStatus.stringValue = "\(pageNote)\(modeName) · \(theme.name) · \(AppSettings.fontName) \(Int(AppSettings.fontSize))pt"
let mins = max(1, Int((Double(words) / 200.0).rounded(.up)))
rightStatus.stringValue = "\(words) words · \(chars) chars · \(lines) lines · ~\(mins) min read"
}
private func cardKindLabel(_ k: BoardCardKind) -> String {
switch k {
case .mainText: return "Main page"
case .text: return "Text page"
case .image: return "Image"
case .box: return "Box"
}
}
func setStatus(_ message: String) { centerStatus.stringValue = message }
// MARK: - Toolbar
private enum ID {
static let mode = NSToolbarItem.Identifier("mode")
static let font = NSToolbarItem.Identifier("font")
static let fontSize = NSToolbarItem.Identifier("fontSize")
static let theme = NSToolbarItem.Identifier("theme")
static let markup = NSToolbarItem.Identifier("markup")
static let wrap = NSToolbarItem.Identifier("wrap")
static let sync = NSToolbarItem.Identifier("sync")
static let postgres = NSToolbarItem.Identifier("postgres")
}
func installToolbar(on window: NSWindow) {
let toolbar = NSToolbar(identifier: "TextPlusToolbar")
toolbar.delegate = self
toolbar.displayMode = .iconOnly
toolbar.allowsUserCustomization = true
toolbar.autosavesConfiguration = true
window.toolbar = toolbar
window.toolbarStyle = .unified
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[ID.mode, .flexibleSpace, ID.markup, .space, ID.font, ID.fontSize, ID.theme,
ID.wrap, .flexibleSpace, ID.sync, ID.postgres]
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[ID.mode, ID.markup, ID.font, ID.fontSize, ID.theme, ID.wrap, ID.sync, ID.postgres,
.flexibleSpace, .space]
}
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier id: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
switch id {
case ID.mode: return modeItem()
case ID.markup: return markupItem()
case ID.font: return fontItem()
case ID.fontSize: return fontSizeItem()
case ID.theme: return themeItem()
case ID.wrap: return wrapItem()
case ID.sync: return buttonItem(id: id, symbol: "arrow.up.circle", label: "Sync",
tip: "Sync Now (⌥⌘S)", action: #selector(toolbarSync))
case ID.postgres: return buttonItem(id: id, symbol: "cylinder.split.1x2", label: "Postgres",
tip: "Send Page to Postgres (⌥⌘P)", action: #selector(toolbarPostgres))
default: return nil
}
}
private func modeItem() -> NSToolbarItem {
let seg = NSSegmentedControl(labels: ["Editor", "Split", "Board"],
trackingMode: .selectOne, target: self, action: #selector(cycleMode(_:)))
seg.selectedSegment = mode.rawValue
seg.segmentStyle = .texturedRounded
modeSegment = seg
let item = NSToolbarItem(itemIdentifier: ID.mode)
item.view = seg
item.label = "View"
item.visibilityPriority = .high
return item
}
private func markupItem() -> NSToolbarItem {
let symbols = ["bold", "italic", "chevron.left.forwardslash.chevron.right",
"number", "list.bullet", "link"]
let seg = NSSegmentedControl()
seg.segmentCount = symbols.count
seg.trackingMode = .momentary
seg.segmentStyle = .texturedRounded
for (i, sym) in symbols.enumerated() {
seg.setImage(NSImage(systemSymbolName: sym, accessibilityDescription: sym), forSegment: i)
seg.setWidth(30, forSegment: i)
}
seg.target = self
seg.action = #selector(markupTapped(_:))
let item = NSToolbarItem(itemIdentifier: ID.markup)
item.view = seg
item.label = "Markup"
return item
}
private func fontItem() -> NSToolbarItem {
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 130, height: 24), pullsDown: false)
popup.addItems(withTitles: AppSettings.fontChoices)
popup.selectItem(withTitle: AppSettings.fontName)
popup.target = self
popup.action = #selector(fontChanged(_:))
let item = NSToolbarItem(itemIdentifier: ID.font)
item.view = popup
item.label = "Font"
return item
}
private func fontSizeItem() -> NSToolbarItem {
let seg = NSSegmentedControl(labels: ["A", "A+"], trackingMode: .momentary,
target: self, action: #selector(fontSizeTapped(_:)))
seg.segmentStyle = .texturedRounded
let item = NSToolbarItem(itemIdentifier: ID.fontSize)
item.view = seg
item.label = "Size"
return item
}
private func themeItem() -> NSToolbarItem {
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 120, height: 24), pullsDown: false)
popup.addItems(withTitles: EditorTheme.all.map { $0.name })
if let idx = EditorTheme.all.firstIndex(where: { $0.id == AppSettings.theme.id }) {
popup.selectItem(at: idx)
}
popup.target = self
popup.action = #selector(themeChanged(_:))
let item = NSToolbarItem(itemIdentifier: ID.theme)
item.view = popup
item.label = "Theme"
return item
}
private func wrapItem() -> NSToolbarItem {
let button = NSButton(image: NSImage(systemSymbolName: "text.alignleft", accessibilityDescription: "Wrap")!,
target: self, action: #selector(toggleWrap))
button.setButtonType(.pushOnPushOff)
button.bezelStyle = .texturedRounded
button.state = AppSettings.softWrap ? .on : .off
let item = NSToolbarItem(itemIdentifier: ID.wrap)
item.view = button
item.label = "Wrap"
item.toolTip = "Toggle soft wrap"
return item
}
private func buttonItem(id: NSToolbarItem.Identifier, symbol: String, label: String,
tip: String, action: Selector) -> NSToolbarItem {
let button = NSButton(image: NSImage(systemSymbolName: symbol, accessibilityDescription: label)!,
target: self, action: action)
button.bezelStyle = .texturedRounded
let item = NSToolbarItem(itemIdentifier: id)
item.view = button
item.label = label
item.toolTip = tip
return item
}
// MARK: - Toolbar actions
@objc private func markupTapped(_ sender: NSSegmentedControl) {
switch sender.selectedSegment {
case 0: document?.markupBold(sender)
case 1: document?.markupItalic(sender)
case 2: document?.markupCode(sender)
case 3: document?.markupHeading(sender)
case 4: document?.markupList(sender)
case 5: document?.markupLink(sender)
default: break
}
}
@objc private func fontChanged(_ sender: NSPopUpButton) {
AppSettings.set(AppSettings.fontNameKey, sender.titleOfSelectedItem ?? "SF Mono")
}
@objc private func fontSizeTapped(_ sender: NSSegmentedControl) {
let delta: CGFloat = sender.selectedSegment == 1 ? 1 : -1
let size = max(8, min(48, AppSettings.fontSize + delta))
AppSettings.set(AppSettings.fontSizeKey, Double(size))
}
@objc private func themeChanged(_ sender: NSPopUpButton) {
let theme = EditorTheme.all[sender.indexOfSelectedItem]
AppSettings.set(AppSettings.themeKey, theme.id)
}
@objc private func toggleWrap(_ sender: NSButton) {
AppSettings.set(AppSettings.softWrapKey, sender.state == .on)
}
@objc private func toolbarSync() { document?.syncNow(nil) }
@objc private func toolbarPostgres() { document?.sendToPostgres(nil) }
}