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>
263 lines
14 KiB
Swift
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
|
|
}
|
|
}
|