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

263 lines
14 KiB
Swift

import AppKit
import SwiftUI
final class AppDelegate: NSObject, NSApplicationDelegate {
private var settingsWindow: NSWindow?
func applicationWillFinishLaunching(_ notification: Notification) {
NSApp.mainMenu = buildMainMenu()
}
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.activate(ignoringOtherApps: true)
}
func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool {
true
}
// Hold termination until in-flight scp uploads finish (bounded), so a
// Cmd-Q-then-save sync isn't abandoned or its failure swallowed.
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard SyncEngine.inFlightUploads > 0 else { return .terminateNow }
SyncEngine.onIdle = {
SyncEngine.onIdle = nil
NSApp.reply(toApplicationShouldTerminate: true)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 20) {
if SyncEngine.onIdle != nil {
SyncEngine.onIdle = nil
NSApp.reply(toApplicationShouldTerminate: true)
}
}
return .terminateLater
}
@objc func showSettings(_ sender: Any?) {
if settingsWindow == nil {
let host = NSHostingController(rootView: SettingsView())
let window = NSWindow(contentViewController: host)
window.title = "TextPlus Settings"
window.styleMask.remove([.resizable, .miniaturizable])
window.isReleasedWhenClosed = false
window.center()
settingsWindow = window
}
settingsWindow?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
// MARK: - Menu
private func buildMainMenu() -> NSMenu {
let mainMenu = NSMenu()
// App menu
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
appMenu.addItem(withTitle: "About TextPlus",
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
keyEquivalent: "")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Settings…",
action: #selector(AppDelegate.showSettings(_:)),
keyEquivalent: ",")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Hide TextPlus",
action: #selector(NSApplication.hide(_:)),
keyEquivalent: "h")
let hideOthers = appMenu.addItem(withTitle: "Hide Others",
action: #selector(NSApplication.hideOtherApplications(_:)),
keyEquivalent: "h")
hideOthers.keyEquivalentModifierMask = [.command, .option]
appMenu.addItem(withTitle: "Show All",
action: #selector(NSApplication.unhideAllApplications(_:)),
keyEquivalent: "")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Quit TextPlus",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q")
// File menu
let fileMenuItem = NSMenuItem()
mainMenu.addItem(fileMenuItem)
let fileMenu = NSMenu(title: "File")
fileMenuItem.submenu = fileMenu
fileMenu.addItem(withTitle: "New",
action: #selector(NSDocumentController.newDocument(_:)),
keyEquivalent: "n")
fileMenu.addItem(withTitle: "Open…",
action: #selector(NSDocumentController.openDocument(_:)),
keyEquivalent: "o")
// AppKit auto-inserts a managed "Open Recent" submenu after the
// item whose action is openDocument: don't build one by hand.
fileMenu.addItem(.separator())
fileMenu.addItem(withTitle: "Close",
action: #selector(NSWindow.performClose(_:)),
keyEquivalent: "w")
fileMenu.addItem(withTitle: "Save",
action: #selector(NSDocument.save(_:)),
keyEquivalent: "s")
let saveAs = fileMenu.addItem(withTitle: "Save As…",
action: #selector(NSDocument.saveAs(_:)),
keyEquivalent: "S")
saveAs.keyEquivalentModifierMask = [.command, .shift]
fileMenu.addItem(withTitle: "Revert to Saved",
action: #selector(NSDocument.revertToSaved(_:)),
keyEquivalent: "")
fileMenu.addItem(.separator())
let syncNow = fileMenu.addItem(withTitle: "Sync Now",
action: #selector(Document.syncNow(_:)),
keyEquivalent: "s")
syncNow.keyEquivalentModifierMask = [.command, .option]
// Edit menu
let editMenuItem = NSMenuItem()
mainMenu.addItem(editMenuItem)
let editMenu = NSMenu(title: "Edit")
editMenuItem.submenu = editMenu
let undo = editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
undo.target = nil
let redo = editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
redo.keyEquivalentModifierMask = [.command, .shift]
editMenu.addItem(.separator())
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
editMenu.addItem(.separator())
let findItem = editMenu.addItem(withTitle: "Find…",
action: #selector(NSTextView.performFindPanelAction(_:)),
keyEquivalent: "f")
findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue)
let findNext = editMenu.addItem(withTitle: "Find Next",
action: #selector(NSTextView.performFindPanelAction(_:)),
keyEquivalent: "g")
findNext.tag = Int(NSTextFinder.Action.nextMatch.rawValue)
let findPrev = editMenu.addItem(withTitle: "Find Previous",
action: #selector(NSTextView.performFindPanelAction(_:)),
keyEquivalent: "G")
findPrev.keyEquivalentModifierMask = [.command, .shift]
findPrev.tag = Int(NSTextFinder.Action.previousMatch.rawValue)
let useSelection = editMenu.addItem(withTitle: "Use Selection for Find",
action: #selector(NSTextView.performFindPanelAction(_:)),
keyEquivalent: "e")
useSelection.tag = Int(NSTextFinder.Action.setSearchString.rawValue)
// View menu
let viewMenuItem = NSMenuItem()
mainMenu.addItem(viewMenuItem)
let viewMenu = NSMenu(title: "View")
viewMenuItem.submenu = viewMenu
let editorMode = viewMenu.addItem(withTitle: "Editor",
action: #selector(Document.showEditorMode(_:)),
keyEquivalent: "1")
editorMode.keyEquivalentModifierMask = [.command, .control]
let splitMode = viewMenu.addItem(withTitle: "Editor + Preview",
action: #selector(Document.showSplitMode(_:)),
keyEquivalent: "2")
splitMode.keyEquivalentModifierMask = [.command, .control]
let boardMode = viewMenu.addItem(withTitle: "Board",
action: #selector(Document.showBoardMode(_:)),
keyEquivalent: "3")
boardMode.keyEquivalentModifierMask = [.command, .control]
viewMenu.addItem(.separator())
let bigger = viewMenu.addItem(withTitle: "Make Text Bigger",
action: #selector(Document.makeTextBigger(_:)),
keyEquivalent: "+")
bigger.keyEquivalentModifierMask = [.command]
// "+" is a shifted key, so that item only fires on =. This hidden
// alternate makes plain = work too, like TextEdit.
let biggerAlternate = viewMenu.addItem(withTitle: "Make Text Bigger",
action: #selector(Document.makeTextBigger(_:)),
keyEquivalent: "=")
biggerAlternate.keyEquivalentModifierMask = [.command]
biggerAlternate.isAlternate = true
viewMenu.addItem(withTitle: "Make Text Smaller",
action: #selector(Document.makeTextSmaller(_:)),
keyEquivalent: "-")
viewMenu.addItem(.separator())
viewMenu.addItem(withTitle: "Toggle Soft Wrap",
action: #selector(Document.toggleSoftWrap(_:)),
keyEquivalent: "")
viewMenu.addItem(withTitle: "Toggle Line Numbers",
action: #selector(Document.toggleLineNumbers(_:)),
keyEquivalent: "")
let cycleTheme = viewMenu.addItem(withTitle: "Cycle Theme",
action: #selector(Document.cycleTheme(_:)),
keyEquivalent: "t")
cycleTheme.keyEquivalentModifierMask = [.command, .control]
// Format menu (Markdown markup)
let formatMenuItem = NSMenuItem()
mainMenu.addItem(formatMenuItem)
let formatMenu = NSMenu(title: "Format")
formatMenuItem.submenu = formatMenu
let boldItem = formatMenu.addItem(withTitle: "Bold",
action: #selector(Document.markupBold(_:)), keyEquivalent: "b")
boldItem.keyEquivalentModifierMask = [.command]
let italicItem = formatMenu.addItem(withTitle: "Italic",
action: #selector(Document.markupItalic(_:)), keyEquivalent: "i")
italicItem.keyEquivalentModifierMask = [.command]
let codeItem = formatMenu.addItem(withTitle: "Inline Code",
action: #selector(Document.markupCode(_:)), keyEquivalent: "k")
codeItem.keyEquivalentModifierMask = [.command, .shift]
formatMenu.addItem(withTitle: "Strikethrough",
action: #selector(Document.markupStrikethrough(_:)), keyEquivalent: "")
formatMenu.addItem(.separator())
formatMenu.addItem(withTitle: "Heading",
action: #selector(Document.markupHeading(_:)), keyEquivalent: "")
formatMenu.addItem(withTitle: "Bullet List",
action: #selector(Document.markupList(_:)), keyEquivalent: "")
formatMenu.addItem(withTitle: "Block Quote",
action: #selector(Document.markupQuote(_:)), keyEquivalent: "")
let linkItem = formatMenu.addItem(withTitle: "Link",
action: #selector(Document.markupLink(_:)), keyEquivalent: "k")
linkItem.keyEquivalentModifierMask = [.command]
// Tools menu
let toolsMenuItem = NSMenuItem()
mainMenu.addItem(toolsMenuItem)
let toolsMenu = NSMenu(title: "Tools")
toolsMenuItem.submenu = toolsMenu
let listSel = toolsMenu.addItem(withTitle: "List Div & Class Selectors",
action: #selector(Document.listSelectors(_:)),
keyEquivalent: "l")
listSel.keyEquivalentModifierMask = [.command, .option]
let extractSel = toolsMenu.addItem(withTitle: "Extract Text for Selector…",
action: #selector(Document.extractForSelector(_:)),
keyEquivalent: "e")
extractSel.keyEquivalentModifierMask = [.command, .option]
toolsMenu.addItem(.separator())
let listings = toolsMenu.addItem(withTitle: "Extract Discogs Listings",
action: #selector(Document.extractListings(_:)),
keyEquivalent: "d")
listings.keyEquivalentModifierMask = [.command, .option]
let sendPG = toolsMenu.addItem(withTitle: "Send Page to Postgres",
action: #selector(Document.sendToPostgres(_:)),
keyEquivalent: "p")
sendPG.keyEquivalentModifierMask = [.command, .option]
// Window menu
let windowMenuItem = NSMenuItem()
mainMenu.addItem(windowMenuItem)
let windowMenu = NSMenu(title: "Window")
windowMenuItem.submenu = windowMenu
windowMenu.addItem(withTitle: "Minimize",
action: #selector(NSWindow.performMiniaturize(_:)),
keyEquivalent: "m")
windowMenu.addItem(withTitle: "Zoom",
action: #selector(NSWindow.performZoom(_:)),
keyEquivalent: "")
windowMenu.addItem(.separator())
windowMenu.addItem(withTitle: "Bring All to Front",
action: #selector(NSApplication.arrangeInFront(_:)),
keyEquivalent: "")
NSApp.windowsMenu = windowMenu
return mainMenu
}
}