commit 09972ccde18a8d87d6e7271545f755865f3bc510 Author: monster Date: Sun Jun 14 16:08:59 2026 +1000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c14e9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Build artifact +TextPlus.app/ + +# Board / canvas sidecars (per-document runtime data) +*.board/ +*.canvas.json +*.canvas.png + +# macOS +.DS_Store diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..cc294e1 --- /dev/null +++ b/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleName + TextPlus + CFBundleDisplayName + TextPlus + CFBundleExecutable + TextPlus + CFBundleIdentifier + local.textplus + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 14.0 + NSPrincipalClass + NSApplication + NSHighResolutionCapable + + CFBundleDocumentTypes + + + CFBundleTypeName + Plain Text Document + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + + LSItemContentTypes + + public.plain-text + + NSDocumentClass + TextPlus.Document + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..71b35c6 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# TextPlus + +A TextEdit-style **plaintext-only** editor for macOS that can automatically +`scp` every file you save to another Mac on your Tailscale network — and that +grew a Markdown preview, themes, an HTML→Postgres scraping pipeline, and a +Freeform-style spatial **board**. + +No rich text, no smart quotes, no autocorrect — just text, plus a lot of hooks. + +## Three view modes + +The toolbar segmented control (top-left) and ⌃⌘1 / ⌃⌘2 / ⌃⌘3 switch between: + +- **Editor** — the plain text editor: line-number gutter, 5 themes, soft wrap, + font controls, find. +- **Split** — editor on the left, a live **Markdown preview** on the right. +- **Board** — a Freeform-style infinite, zoomable canvas. Wheel shortcuts: + **⌃-scroll zooms** (centered on the pointer), **⌥-scroll pans horizontally**, + **⌘-scroll pans vertically**; a plain wheel scrolls normally and pinch zooms. + Your document lives here as a **page card** you can place + anywhere; arrange clean **boxes**, drop **images**, and sketch with the pen. + **Double-click or press Return on the page to focus it** — the board zooms in + and the card becomes a normal text editor bound to the document. Click empty + space or press ⎋ to zoom back out. + +Board objects: + +- **Page card** (the main one) — *is* the `.txt`; editing it edits the document. + Set its size with the **ruler button** (US Letter / A4 / Legal / Square / + Wide 16:9 / Note) and the **P/L** orientation toggle in the palette. +- **Text pages** ("New text page" button) — extra pages created at the current + page size, freely resizable; saved in the sidecar, not the `.txt`. + **Select a text page and switch to the Editor tab to edit it full-screen** — + the Editor follows whichever page is selected on the board (the status bar + shows "Board note" when you're editing one of these instead of the main page). +- **Boxes** (Box tool, drag to draw) — clean translucent rectangles for diagrams, + draggable / resizable / grid-snapped. +- **Images** — the Add Image button, paste (⌘V), or drag a file onto the board. +- **Freehand** — pen / marker / line / arrow / oval / eraser, drawn behind the cards. + +One color drives the **pen, shapes, and boxes** together — click the color well +for the full picker, or the **eyedropper** to grab a color from anywhere on +screen. **Zoom to Fit** frames everything; the board exports to PNG or sends to +your tailnet like a saved file. + +**Right-click anywhere on the board** for a full nested menu — tools, new +page/image, page size & orientation, zoom, color (swatches / screen-pick / +picker), view mode, theme, undo/redo, export/send, plus per-card actions +(edit, bring to front / send to back, duplicate, delete) when you click a card. +Everything is reachable from the mouse. + +The **status bar** is mode-aware: in the editor it shows line/column, selection, +words/chars/lines and reading time; in board mode it shows the active tool, page +size/orientation (or the selected card's dimensions), zoom %, and card count. + +**Settings → Board** (⌘,) sets the default page size, orientation, and grid +snapping for new documents. + +The board layout persists in a sidecar bundle next to the document +(`foo.txt.board/`: `board.json` + image assets + a `preview.png`). The `.txt` +itself stays pure plaintext — open it in any other editor and you just see the +page's prose. Delete the `foo.txt.board/` folder to reset the board. + +## Build + +```sh +./build.sh +``` + +Produces `TextPlus.app` in this folder. Drag it to `/Applications` if you want +it in Launchpad/Spotlight, or just `open TextPlus.app`. + +Requires only the Xcode Command Line Tools (`xcode-select --install`) — no Xcode. + +## Set up sync + +1. Open **TextPlus → Settings…** (⌘,) +2. **Remote**: `user@host` of the target Mac. Tailscale MagicDNS names work + (`mini@studio`), as do raw Tailscale IPs (`mini@100.x.y.z`) and any alias + from your `~/.ssh/config` (then you can leave out the `user@` part). +3. **Folder**: the directory on the target Mac to drop files into, + e.g. `/Users/you/Documents/inbox`. Leave empty for the remote home folder. +4. Hit **Test Connection** — it checks both SSH auth and that the folder exists. +5. Turn on **Copy file to remote Mac on every save**. + +Now every ⌘S also fires an `scp` in the background. The window subtitle shows +`Syncing… → Synced ✓ 10:42` or the error if it failed. **File → Sync Now** +(⌥⌘S) re-pushes the current file any time, even with the toggle off. + +### One-time SSH key setup (if `ssh user@host` still asks for a password) + +On the target Mac: **System Settings → General → Sharing → Remote Login** on. + +On this Mac: + +```sh +ssh-keygen -t ed25519 # skip if ~/.ssh/id_ed25519 already exists +ssh-copy-id user@host # copies your key to the target Mac +ssh user@host # should now log in with no password prompt +``` + +TextPlus runs scp with `BatchMode=yes`, so it will never pop a password +prompt — if key auth isn't working, the sync just fails with the error shown +in the window subtitle. + +## HTML scraping tools + +Paste `` HTML copied from the browser inspector into a document, then: + +- **Tools → List Div & Class Selectors** (⌥⌘L) — opens a new document listing + every unique selector (`tag#id.class`) with a count and a sample of its text, + sorted by count so repeating listing rows float to the top. +- **Tools → Extract Text for Selector…** (⌥⌘E) — type a selector and get every + match's text in a new document, one match per line, table cells tab-separated. + Supports `tag`, `.class`, `#id`, combos (`tr.shortcut_navigable`), and + space-separated descendant chains (`table.table_block td`). No child + combinator (`>`) — use a space instead. + +Selector extras (work in the Extract dialog and in scrape profiles): + +- `@attr` at the end pulls an attribute instead of text, as the first column: + `a.item_description_title@href`, `span.price@data-pricevalue`. +- `[attr]` requires the attribute, `[!attr]` requires its absence, + `[attr=value]` exact match, `[attr^=value]` prefix: + `.item_condition span[!class]`, `a[href^=/seller/]`. + +Workflow: paste → ⌥⌘L → spot the selector with the right count/sample → ⌥⌘E. + +## Postgres ingest (the scraper pipeline) + +This turns the editor into the front end of your Discogs snapshot database. +Copy a marketplace page's `` from the inspector, paste it into a +document with **the page URL on the first line**, and either: + +- **Tools → Send Page to Postgres** (⌥⌘P) — parse and store right now, or +- turn on **auto-ingest** in Settings so every save stores automatically. + +What gets stored (tables auto-created on first connect): + +| Table | Contents | +|---|---| +| `tp_pages` | one row per captured page (url, source, content hash, counts, timestamp) | +| `tp_selectors` | the full selector summary for that page | +| `tp_rows` | every extracted row as JSONB, keyed to the page | +| `discogs_sellers` | `seller_id`, username — upserted by `data-seller-id` | +| `marketplace_snapshots` | one row per listing, upserted by `data-item-id` | + +`marketplace_snapshots` matches the schema from your scraper notes: +`item_id`, `release_id` (your bridge to the local XML dump), `seller_id`, +title, listing URL, media/sleeve condition, currency, `price_value`, +`shipping_value`, and `first_seen` / `last_seen` / `is_active` for tracking +state over time. Re-ingesting a page **upserts**: existing listings get +`last_seen` and price refreshed (`first_seen` preserved); new ones inserted. + +Setup: **Settings → Postgres**. The connection URI defaults to +`postgresql://localhost:5433/scraperrr` (port 5433 because a Tailscale ssh +tunnel already owns 5432 on this Mac). Hit **Test & Create Tables**, then flip +auto-ingest if you want it. Ingest shells out to `psql` +(`brew install postgresql@17`); it does **not** fetch pages — you paste the +DOM, which sidesteps Cloudflare entirely, exactly as your notes planned. + +Recognized pages (the `discogs_marketplace` profile): `sell/list`, +`sell/mywants`, and `seller//profile`. Any other HTML still gets its +page + selector summary stored; only the structured listing tables are +Discogs-specific. + +## Notes + +- Sync fires only on explicit saves (⌘S / Save As), never on autosave — + half-typed files don't get pushed. +- Files are written as UTF-8. Opening files in other encodings works + (encoding is auto-detected); they're converted to UTF-8 on save. +- `StrictHostKeyChecking=accept-new` is used: first connection to a new host + is trusted automatically (fine on a tailnet), but a *changed* host key is + still rejected. +- Settings live in `defaults read local.textplus`. diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000..36119cd Binary files /dev/null and b/Resources/AppIcon.icns differ diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift new file mode 100644 index 0000000..e97c688 --- /dev/null +++ b/Sources/AppDelegate.swift @@ -0,0 +1,262 @@ +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 + } +} diff --git a/Sources/Appearance.swift b/Sources/Appearance.swift new file mode 100644 index 0000000..f2a9bfe --- /dev/null +++ b/Sources/Appearance.swift @@ -0,0 +1,147 @@ +import AppKit + +/// App-wide editor appearance: theme, font family, font size, soft-wrap and +/// line-number toggles. Persisted in UserDefaults and broadcast so every open +/// document window updates together. +enum AppSettings { + static let themeKey = "editorThemeID" + static let fontNameKey = "editorFontName" + static let fontSizeKey = "editorFontSize" + static let softWrapKey = "editorSoftWrap" + static let lineNumbersKey = "editorLineNumbers" + static let boardPageSizeKey = "boardPageSize" + static let boardOrientationKey = "boardOrientation" + static let boardSnapKey = "boardSnap" + + static let didChange = Notification.Name("TextPlusAppearanceDidChange") + + static var theme: EditorTheme { + EditorTheme.byID(UserDefaults.standard.string(forKey: themeKey)) } + + static var fontSize: CGFloat { + let s = UserDefaults.standard.double(forKey: fontSizeKey) + return s >= 6 ? CGFloat(s) : 13 + } + + static var fontName: String { + UserDefaults.standard.string(forKey: fontNameKey) ?? "SF Mono" + } + + static var softWrap: Bool { + UserDefaults.standard.object(forKey: softWrapKey) as? Bool ?? true + } + + static var lineNumbers: Bool { + UserDefaults.standard.object(forKey: lineNumbersKey) as? Bool ?? true + } + + static var boardPageSize: String { + UserDefaults.standard.string(forKey: boardPageSizeKey) ?? "letter" + } + + static var boardOrientation: PageOrientation { + PageOrientation(rawValue: UserDefaults.standard.string(forKey: boardOrientationKey) ?? "portrait") ?? .portrait + } + + static var boardSnap: Bool { + UserDefaults.standard.object(forKey: boardSnapKey) as? Bool ?? true + } + + static var editorFont: NSFont { + font(named: fontName, size: fontSize) + } + + static func font(named name: String, size: CGFloat) -> NSFont { + if name == "SF Mono" { + return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + } + if name == "System" { + return NSFont.systemFont(ofSize: size) + } + return NSFont(name: name, size: size) + ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular) + } + + /// Curated font menu — monospaced first (best for code/scraping), then a + /// couple of proportional faces for prose. + static let fontChoices: [String] = [ + "SF Mono", "Menlo", "Monaco", "Courier New", + "System", "Helvetica Neue", "Georgia", + ] + + static func set(_ key: String, _ value: Any) { + UserDefaults.standard.set(value, forKey: key) + NotificationCenter.default.post(name: didChange, object: nil) + } +} + +/// A complete editor color scheme. Markdown preview and the line-number gutter +/// derive their colors from the same palette so everything stays consistent. +struct EditorTheme: Equatable { + let id: String + let name: String + let isDark: Bool + let background: NSColor + let foreground: NSColor + let caret: NSColor + let selection: NSColor + let gutterBackground: NSColor + let gutterText: NSColor + let currentLine: NSColor + let accent: NSColor + let secondaryText: NSColor + + static func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: CGFloat = 1) -> NSColor { + NSColor(srgbRed: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: a) + } + + static let system = EditorTheme( + id: "system", name: "System", isDark: false, + background: .textBackgroundColor, foreground: .textColor, + caret: .textColor, selection: .selectedTextBackgroundColor, + gutterBackground: rgb(245, 245, 247), gutterText: rgb(160, 160, 168), + currentLine: rgb(0, 0, 0, 0.04), accent: .controlAccentColor, + secondaryText: .secondaryLabelColor) + + static let dark = EditorTheme( + id: "dark", name: "Midnight", isDark: true, + background: rgb(24, 26, 31), foreground: rgb(222, 226, 233), + caret: rgb(120, 200, 255), selection: rgb(56, 84, 130), + gutterBackground: rgb(19, 21, 25), gutterText: rgb(95, 102, 115), + currentLine: rgb(255, 255, 255, 0.05), accent: rgb(120, 200, 255), + secondaryText: rgb(150, 158, 172)) + + static let sepia = EditorTheme( + id: "sepia", name: "Sepia", isDark: false, + background: rgb(247, 240, 224), foreground: rgb(60, 50, 38), + caret: rgb(150, 90, 40), selection: rgb(220, 200, 160), + gutterBackground: rgb(240, 231, 211), gutterText: rgb(170, 150, 120), + currentLine: rgb(120, 90, 40, 0.07), accent: rgb(165, 100, 40), + secondaryText: rgb(130, 112, 86)) + + static let solarized = EditorTheme( + id: "solarized", name: "Solarized", isDark: true, + background: rgb(0, 43, 54), foreground: rgb(147, 161, 161), + caret: rgb(38, 139, 210), selection: rgb(7, 54, 66), + gutterBackground: rgb(0, 37, 46), gutterText: rgb(88, 110, 117), + currentLine: rgb(255, 255, 255, 0.04), accent: rgb(42, 161, 152), + secondaryText: rgb(101, 123, 131)) + + static let nord = EditorTheme( + id: "nord", name: "Nord", isDark: true, + background: rgb(46, 52, 64), foreground: rgb(216, 222, 233), + caret: rgb(136, 192, 208), selection: rgb(67, 76, 94), + gutterBackground: rgb(40, 45, 56), gutterText: rgb(110, 120, 138), + currentLine: rgb(255, 255, 255, 0.05), accent: rgb(143, 188, 187), + secondaryText: rgb(150, 160, 178)) + + static let all: [EditorTheme] = [system, dark, sepia, solarized, nord] + + static func byID(_ id: String?) -> EditorTheme { + all.first { $0.id == id } ?? system + } + + var appearance: NSAppearance? { + NSAppearance(named: isDark ? .darkAqua : .aqua) + } +} diff --git a/Sources/Board.swift b/Sources/Board.swift new file mode 100644 index 0000000..00cacf1 --- /dev/null +++ b/Sources/Board.swift @@ -0,0 +1,184 @@ +import AppKit + +/// What a card on the freeform board is. The single `.mainText` card hosts the +/// document's real text view (its text == the .txt bytes, never serialized here). +/// Other kinds are board-only objects stored in the sidecar. +enum BoardCardKind: String, Codable { + case mainText, text, image, box +} + +enum PageOrientation: String, Codable { + case portrait, landscape + var toggled: PageOrientation { self == .portrait ? .landscape : .portrait } +} + +/// A named page-size preset (point dimensions at 72pt/inch). `portrait` here is +/// always width ≤ height; orientation swaps for non-square sizes. +enum PageSize: String, Codable, CaseIterable { + case letter, a4, legal, square, wide, note + + var label: String { + switch self { + case .letter: return "US Letter" + case .a4: return "A4" + case .legal: return "US Legal" + case .square: return "Square" + case .wide: return "Wide 16:9" + case .note: return "Note" + } + } + + var shortLabel: String { + switch self { + case .letter: return "Letter" + case .a4: return "A4" + case .legal: return "Legal" + case .square: return "Square" + case .wide: return "Wide" + case .note: return "Note" + } + } + + private var portraitBase: CGSize { + switch self { + case .letter: return CGSize(width: 612, height: 792) // 8.5 × 11" + case .a4: return CGSize(width: 595, height: 842) + case .legal: return CGSize(width: 612, height: 1008) // 8.5 × 14" + case .square: return CGSize(width: 700, height: 700) + case .wide: return CGSize(width: 540, height: 960) // 9:16 portrait of 16:9 + case .note: return CGSize(width: 300, height: 220) + } + } + + func size(_ orientation: PageOrientation) -> CGSize { + if self == .square { return portraitBase } + let b = portraitBase + return orientation == .landscape ? CGSize(width: b.height, height: b.width) : b + } + + static func from(_ raw: String?) -> PageSize { raw.flatMap(PageSize.init(rawValue:)) ?? .letter } +} + +/// One object on the board, in world (board) coordinates. Codable with a +/// lenient decoder so a partial or older sidecar never throws. +struct BoardCard: Codable { + var id: UUID + var kind: BoardCardKind + var frame: CGRect // world coords (flipped board, origin top-left) + var z: Int // explicit stacking order + + var text: String? // .text annotations only; nil for mainText + var orientation: PageOrientation? // mainText/text page orientation + var pageSize: String? // PageSize raw value for mainText/text cards + var imageFile: String? // relative filename inside foo.txt.board/ + var fillHex: String? // .box + var strokeHex: String? // .box + var cornerRadius: CGFloat? // .box + var title: String? // optional label on any card + + init(id: UUID = UUID(), kind: BoardCardKind, frame: CGRect, z: Int, + text: String? = nil, orientation: PageOrientation? = nil, pageSize: String? = nil, + imageFile: String? = nil, fillHex: String? = nil, strokeHex: String? = nil, + cornerRadius: CGFloat? = nil, title: String? = nil) { + self.id = id; self.kind = kind; self.frame = frame; self.z = z + self.text = text; self.orientation = orientation; self.pageSize = pageSize + self.imageFile = imageFile; self.fillHex = fillHex; self.strokeHex = strokeHex + self.cornerRadius = cornerRadius; self.title = title + } + + enum CodingKeys: String, CodingKey { + case id, kind, frame, z, text, orientation, pageSize, imageFile, fillHex, strokeHex, cornerRadius, title + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = (try? c.decode(UUID.self, forKey: .id)) ?? UUID() + kind = (try? c.decode(BoardCardKind.self, forKey: .kind)) ?? .text + frame = (try? c.decode(CGRect.self, forKey: .frame)) ?? CGRect(x: 80, y: 80, width: 240, height: 160) + z = (try? c.decode(Int.self, forKey: .z)) ?? 0 + text = try? c.decodeIfPresent(String.self, forKey: .text) + orientation = try? c.decodeIfPresent(PageOrientation.self, forKey: .orientation) + pageSize = try? c.decodeIfPresent(String.self, forKey: .pageSize) + imageFile = try? c.decodeIfPresent(String.self, forKey: .imageFile) + fillHex = try? c.decodeIfPresent(String.self, forKey: .fillHex) + strokeHex = try? c.decodeIfPresent(String.self, forKey: .strokeHex) + cornerRadius = try? c.decodeIfPresent(CGFloat.self, forKey: .cornerRadius) + title = try? c.decodeIfPresent(String.self, forKey: .title) + } +} + +/// The persisted board state for a document. Stored in foo.txt.board/board.json. +/// Reuses the paint canvas's existing Codable doc verbatim for the shapes layer. +struct BoardDoc: Codable { + var version: Int + var cards: [BoardCard] + var shapes: DrawCanvasView.CanvasDoc + var magnification: CGFloat + var scrollOrigin: CGPoint + var boardBackgroundHex: String? + + init(version: Int = 1, cards: [BoardCard], shapes: DrawCanvasView.CanvasDoc, + magnification: CGFloat = 1, scrollOrigin: CGPoint = .zero, boardBackgroundHex: String? = nil) { + self.version = version; self.cards = cards; self.shapes = shapes + self.magnification = magnification; self.scrollOrigin = scrollOrigin + self.boardBackgroundHex = boardBackgroundHex + } + + enum CodingKeys: String, CodingKey { + case version, cards, shapes, magnification, scrollOrigin, boardBackgroundHex + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + version = (try? c.decode(Int.self, forKey: .version)) ?? 1 + cards = (try? c.decode([BoardCard].self, forKey: .cards)) ?? [] + shapes = (try? c.decode(DrawCanvasView.CanvasDoc.self, forKey: .shapes)) + ?? DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: 0, height: 0) + magnification = (try? c.decode(CGFloat.self, forKey: .magnification)) ?? 1 + scrollOrigin = (try? c.decode(CGPoint.self, forKey: .scrollOrigin)) ?? .zero + boardBackgroundHex = try? c.decodeIfPresent(String.self, forKey: .boardBackgroundHex) + } + + /// A fresh board with a single empty main page card, centered in the world. + static func makeDefault(worldSize: CGSize, + pageSize: PageSize = .from(AppSettings.boardPageSize), + orientation: PageOrientation = AppSettings.boardOrientation) -> BoardDoc { + let page = pageSize.size(orientation) + let origin = CGPoint(x: (worldSize.width - page.width) / 2, + y: max(120, (worldSize.height - page.height) / 2 - 200)) + let main = BoardCard(kind: .mainText, + frame: CGRect(origin: origin, size: page), + z: 0, orientation: orientation, pageSize: pageSize.rawValue) + let empty = DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: worldSize.width, height: worldSize.height) + return BoardDoc(cards: [main], shapes: empty) + } +} + +extension JSONEncoder { + /// Hand-inspectable sidecar JSON, matching the existing canvas sidecar style. + static let boardEncoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = [.prettyPrinted, .sortedKeys] + return e + }() +} + +/// Shared plaintext-safety configuration for any NSTextView used as an editor +/// surface (the document editor and board text cards), so they behave identically. +enum TextViewConfig { + static func harden(_ tv: NSTextView) { + tv.isRichText = false + tv.importsGraphics = false + tv.usesFontPanel = false + tv.allowsUndo = true + tv.isAutomaticQuoteSubstitutionEnabled = false + tv.isAutomaticDashSubstitutionEnabled = false + tv.isAutomaticTextReplacementEnabled = false + tv.isAutomaticSpellingCorrectionEnabled = false + tv.isAutomaticLinkDetectionEnabled = false + tv.isAutomaticDataDetectionEnabled = false + tv.smartInsertDeleteEnabled = false + tv.isContinuousSpellCheckingEnabled = false + tv.enabledTextCheckingTypes = 0 + } +} diff --git a/Sources/BoardContainer.swift b/Sources/BoardContainer.swift new file mode 100644 index 0000000..b652dc5 --- /dev/null +++ b/Sources/BoardContainer.swift @@ -0,0 +1,1348 @@ +import AppKit + +/// The active board tool. `select` manipulates cards; `box` drops clean +/// rectangles; the rest forward to the freehand shapes layer. +enum BoardTool: Equatable { + case select, box, pen, marker, line, arrow, oval, eraser + + static let ordered: [BoardTool] = [.select, .box, .pen, .marker, .line, .arrow, .oval, .eraser] + + var name: String { + switch self { + case .select: return "Select" + case .box: return "Box" + case .pen: return "Pen" + case .marker: return "Marker" + case .line: return "Line" + case .arrow: return "Arrow" + case .oval: return "Oval" + case .eraser: return "Eraser" + } + } + + var isDraw: Bool { + switch self { case .select, .box: return false; default: return true } + } + var drawTool: DrawTool? { + switch self { + case .pen: return .pen + case .marker: return .marker + case .line: return .line + case .arrow: return .arrow + case .oval: return .oval + case .eraser: return .eraser + default: return nil + } + } +} + +// MARK: - Card view + +/// One on-board object: a page/text card (bare NSTextView), an image, or a clean +/// box. While idle it swallows clicks so the board can select/drag it; while +/// focused (text editing) it passes clicks through to its text view for the caret. +final class CardView: NSView { + let cardID: UUID + var kind: BoardCardKind + weak var board: BoardView? + + private(set) var textView: NSTextView? // text / mainText cards + private(set) var imageView: NSImageView? // image cards + var ownsTextView: Bool // false for the document's main text view + var isFocused = false + var title: String? + + // box styling + var fillColor: NSColor = .clear + var strokeColor: NSColor = .clear + var boxCornerRadius: CGFloat = 8 + + override var isFlipped: Bool { true } + + init(card: BoardCard, board: BoardView) { + self.cardID = card.id + self.kind = card.kind + self.board = board + self.ownsTextView = (card.kind != .mainText) + self.title = card.title + super.init(frame: card.frame) + wantsLayer = true + translatesAutoresizingMaskIntoConstraints = true + autoresizesSubviews = true + configure(for: card) + } + + required init?(coder: NSCoder) { fatalError() } + + private func configure(for card: BoardCard) { + switch card.kind { + case .mainText: + break // the document's real text view is adopted later + case .text: + let tv = NSTextView(frame: bounds.insetBy(dx: 10, dy: 10)) + TextViewConfig.harden(tv) + // The document is the delegate so edits route undo through it and + // mark the doc dirty (secondary-page text is saved to the sidecar). + tv.delegate = board?.document + tv.autoresizingMask = [.width, .height] + tv.drawsBackground = false + tv.isEditable = false + tv.isSelectable = false + tv.textContainerInset = NSSize(width: 6, height: 8) + tv.textContainer?.widthTracksTextView = true + tv.isHorizontallyResizable = false + tv.string = card.text ?? "" + addSubview(tv) + textView = tv + case .image: + let iv = NSImageView(frame: bounds) + iv.autoresizingMask = [.width, .height] + iv.imageScaling = .scaleProportionallyUpOrDown + iv.imageFrameStyle = .none + addSubview(iv) + imageView = iv + case .box: + if let f = card.fillHex.flatMap({ NSColor(hex: $0) }) { fillColor = f } + if let s = card.strokeHex.flatMap({ NSColor(hex: $0) }) { strokeColor = s } + if let r = card.cornerRadius { boxCornerRadius = r } + } + } + + /// Bind a text view to this card without mounting it as a subview — used for + /// the main card, whose text view starts life in the editor. + func assignTextView(_ tv: NSTextView) { + textView = tv + ownsTextView = (kind != .mainText) + } + + /// Mount this card's text view as its on-board content (idle, wraps to width). + func mountTextView() { + guard let tv = textView else { return } + tv.removeFromSuperview() + tv.frame = bounds.insetBy(dx: 10, dy: 10) + tv.autoresizingMask = [.width, .height] + tv.drawsBackground = false + tv.textContainerInset = NSSize(width: 6, height: 8) + tv.isHorizontallyResizable = false + tv.textContainer?.widthTracksTextView = true + tv.isEditable = false + tv.isSelectable = false + addSubview(tv) + } + + /// Lift this card's text view out (to host it in the editor); keeps the ref. + @discardableResult + func unmountTextView() -> NSTextView? { + textView?.removeFromSuperview() + return textView + } + + func setImage(_ image: NSImage) { imageView?.image = image } + + // MARK: drawing (page chrome / box fill) + + override func draw(_ dirtyRect: NSRect) { + guard let theme = board?.theme else { return } + let r = bounds + switch kind { + case .mainText, .text: + let bg = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: 6, yRadius: 6) + // Page uses the editor's own background so the theme.foreground text + // stays readable in every theme (white page would hide light text). + theme.background.setFill() + bg.fill() + (isFocused ? theme.accent : theme.secondaryText.withAlphaComponent(0.35)).setStroke() + bg.lineWidth = isFocused ? 2 : 1 + bg.stroke() + if kind == .text { + // little badge so board-only annotations read as distinct + drawBadge("note", theme: theme) + } + case .image: + let p = NSBezierPath(rect: r) + theme.secondaryText.withAlphaComponent(isFocused ? 0.9 : 0.3).setStroke() + p.lineWidth = isFocused ? 2 : 1 + p.stroke() + case .box: + let p = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: boxCornerRadius, yRadius: boxCornerRadius) + fillColor.setFill(); p.fill() + strokeColor.setStroke(); p.lineWidth = 2; p.stroke() + if let title, !title.isEmpty { drawBoxTitle(title, theme: theme) } + } + } + + private func drawBadge(_ text: String, theme: EditorTheme) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 9, weight: .semibold), + .foregroundColor: theme.secondaryText, + ] + (text as NSString).draw(at: NSPoint(x: 8, y: 4), withAttributes: attrs) + } + + private func drawBoxTitle(_ text: String, theme: EditorTheme) { + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 12, weight: .medium), + .foregroundColor: theme.foreground, + ] + (text as NSString).draw(at: NSPoint(x: 8, y: 6), withAttributes: attrs) + } + + // MARK: hit-testing (the core interaction rule) + + override func hitTest(_ point: NSPoint) -> NSView? { + // Focused: caret/selection clicks go to the text view. + if isFocused { return super.hitTest(point) } + // Idle (any tool): transparent so the BoardView owns select/drag/draw/box. + // (Returning self would steal events from the board; returning the text + // view would place a caret instead of selecting the card.) + return nil + } + + func snapshotText(into card: inout BoardCard) { + if kind == .text { card.text = textView?.string } + } +} + +// MARK: - Selection overlay (visual only; board owns all mouse handling) + +final class SelectionOverlayView: NSView { + weak var board: BoardView? + override var isFlipped: Bool { true } + override func hitTest(_ point: NSPoint) -> NSView? { nil } // transparent to events + + override func draw(_ dirtyRect: NSRect) { + guard let board else { return } + let theme = board.theme + // live box preview (box tool drag) on top of everything + if let b = board.liveBox { + let p = NSBezierPath(roundedRect: b, xRadius: 8, yRadius: 8) + board.boxFill.withAlphaComponent(0.18).setFill(); p.fill() + board.boxStroke.setStroke(); p.lineWidth = 2; p.stroke() + } + guard let card = board.selectedCardView else { return } + let r = card.frame + let sel = NSBezierPath(rect: r.insetBy(dx: -2, dy: -2)) + theme.accent.setStroke() + sel.lineWidth = 1.5 + sel.stroke() + for h in board.handleRects(for: r) { + let hp = NSBezierPath(ovalIn: h) + theme.accent.setFill(); hp.fill() + NSColor.white.setStroke(); hp.lineWidth = 1; hp.stroke() + } + } +} + +// MARK: - Board view (the zooming world) + +final class BoardView: NSView { + static let worldSize = CGSize(width: 8000, height: 6000) + static let handleSize: CGFloat = 9 + + let shapes = DrawCanvasView(frame: NSRect(origin: .zero, size: BoardView.worldSize)) + private let overlay = SelectionOverlayView(frame: NSRect(origin: .zero, size: BoardView.worldSize)) + private(set) var cardViews: [CardView] = [] + private(set) var cards: [BoardCard] = [] + + weak var document: Document? + weak var container: BoardContainerView? + var onChange: (() -> Void)? // mark document dirty + var onSelectionChange: (() -> Void)? // update palette enablement + var theme: EditorTheme = .system + + var tool: BoardTool = .select { + didSet { applyToolGating() } + } + var boxFill: NSColor = .systemBlue + var boxStroke: NSColor = .systemBlue + + private(set) var selectedCardView: CardView? + private(set) var liveBox: NSRect? + private let boardUndo = UndoManager() + + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + override var undoManager: UndoManager? { boardUndo } + + override init(frame frameRect: NSRect) { + super.init(frame: NSRect(origin: .zero, size: BoardView.worldSize)) + wantsLayer = true + shapes.canvasBackground = .clear + shapes.autoresizingMask = [] + // Freehand strokes/erases route straight to the shapes layer, bypassing + // BoardView's mouse handling — so mark the document dirty from here too, + // or a draw-only session wouldn't prompt to save on close. + shapes.onChange = { [weak self] in self?.markDirty() } + addSubview(shapes) // bottom + overlay.board = self + addSubview(overlay) // top + applyToolGating() + registerForDraggedTypes([.fileURL, .png, .tiff]) + } + + func viewportCenter() -> NSPoint { + let r = enclosingScrollView?.documentVisibleRect ?? NSRect(origin: .zero, size: BoardView.worldSize) + return NSPoint(x: r.midX, y: r.midY) + } + + // MARK: paste / drag-drop images + + @objc func paste(_ sender: Any?) { + let pb = NSPasteboard.general + if let images = pb.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage], + let img = images.first { + addImageCard(img, at: viewportCenter()) + } + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { .copy } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let pb = sender.draggingPasteboard + let drop = convert(sender.draggingLocation, from: nil) + if let urls = pb.readObjects(forClasses: [NSURL.self], + options: [.urlReadingContentsConformToTypes: ["public.image"]]) as? [URL], + let url = urls.first, let img = NSImage(contentsOf: url) { + addImageCard(img, at: drop) + return true + } + if let images = pb.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage], + let img = images.first { + addImageCard(img, at: drop) + return true + } + return false + } + + required init?(coder: NSCoder) { fatalError() } + + // MARK: model → views + + func load(doc: BoardDoc) { + var sorted = doc.cards.sorted { $0.z < $1.z } + // Invariant: exactly one main card must exist, or the document's text + // view would have nowhere to live. Synthesize one if a sidecar lacks it. + if !sorted.contains(where: { $0.kind == .mainText }) { + let dims = PageSize.from(AppSettings.boardPageSize).size(.portrait) + let origin = CGPoint(x: (BoardView.worldSize.width - dims.width) / 2, y: 200) + sorted.insert(BoardCard(kind: .mainText, frame: CGRect(origin: origin, size: dims), + z: -1, orientation: .portrait, pageSize: AppSettings.boardPageSize), at: 0) + } + cards = sorted + rebuildCardViews() + shapes.load(from: (try? JSONEncoder().encode(doc.shapes)) ?? Data()) + shapes.canvasBackground = .clear + } + + var snapshotDoc: BoardDoc { + // pull live frames + secondary text back into the model + var out: [BoardCard] = [] + for (i, var card) in cards.enumerated() { + if let v = cardViews.first(where: { $0.cardID == card.id }) { + card.frame = v.frame + if card.kind == .text { card.text = v.textView?.string } + card.z = i + } + if card.kind == .mainText { card.text = nil } // never duplicate the .txt bytes + out.append(card) + } + let shapeDoc = (shapes.serialized()).flatMap { try? JSONDecoder().decode(DrawCanvasView.CanvasDoc.self, from: $0) } + ?? DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: 0, height: 0) + return BoardDoc(cards: out, shapes: shapeDoc) + } + + private func rebuildCardViews() { + for v in cardViews { v.removeFromSuperview() } + cardViews = cards.map { CardView(card: $0, board: self) } + // insert below the overlay, above the shapes layer, in model order + for v in cardViews { addSubview(v, positioned: .below, relativeTo: overlay) } + overlay.needsDisplay = true + } + + var mainCardView: CardView? { cardViews.first { $0.kind == .mainText } } + + // MARK: editor page binding + + /// Which card the Editor tab should show: the selected text card, else the + /// main page. Lets the editor follow the on-board selection. + func editorPageCandidate() -> CardView? { + if let s = selectedCardView, s.kind == .text || s.kind == .mainText { return s } + return mainCardView + } + + /// Bind the document's text view to the main card (it starts in the editor). + func assignMainTextView(_ tv: NSTextView) { + mainCardView?.assignTextView(tv) + } + + /// Theme each on-board text card's text view to the current theme. + func applyTextTheme(font: NSFont, fg: NSColor, caret: NSColor) { + for v in cardViews where v.kind == .text || v.kind == .mainText { + v.textView?.font = font + v.textView?.textColor = fg + v.textView?.insertionPointColor = caret + v.textView?.typingAttributes = [.font: font, .foregroundColor: fg] + } + } + + // MARK: tool gating + + private func applyToolGating() { + shapes.hitTestEnabled = tool.isDraw + if let dt = tool.drawTool { shapes.tool = dt } + if tool != .select { unfocusCard(commit: true) } + window?.invalidateCursorRects(for: self) + } + + func applyDrawStyle(color: NSColor, width: CGFloat, fill: Bool) { + shapes.strokeColor = color + shapes.lineWidth = width + shapes.fillEnabled = fill + } + + // MARK: theming + + func apply(theme: EditorTheme) { + self.theme = theme + // The board "surface" is the gutter shade so the page card (theme.background) + // stands out against it in every theme. + layer?.backgroundColor = theme.gutterBackground.cgColor + for v in cardViews { v.needsDisplay = true } + overlay.needsDisplay = true + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + theme.gutterBackground.setFill() + dirtyRect.fill() + // dot grid + let spacing: CGFloat = 40 + let dot = theme.gutterText.withAlphaComponent(theme.isDark ? 0.18 : 0.25) + dot.setFill() + let startX = (floor(dirtyRect.minX / spacing)) * spacing + let startY = (floor(dirtyRect.minY / spacing)) * spacing + var y = startY + while y <= dirtyRect.maxY { + var x = startX + while x <= dirtyRect.maxX { + NSBezierPath(ovalIn: NSRect(x: x - 1, y: y - 1, width: 2, height: 2)).fill() + x += spacing + } + y += spacing + } + } + + // MARK: selection + handles + + func handleRects(for r: NSRect) -> [NSRect] { + let s = BoardView.handleSize + let pts = [ + NSPoint(x: r.minX, y: r.minY), NSPoint(x: r.midX, y: r.minY), NSPoint(x: r.maxX, y: r.minY), + NSPoint(x: r.minX, y: r.midY), NSPoint(x: r.maxX, y: r.midY), + NSPoint(x: r.minX, y: r.maxY), NSPoint(x: r.midX, y: r.maxY), NSPoint(x: r.maxX, y: r.maxY), + ] + return pts.map { NSRect(x: $0.x - s / 2, y: $0.y - s / 2, width: s, height: s) } + } + + private func handleIndex(at p: NSPoint, for r: NSRect) -> Int? { + for (i, h) in handleRects(for: r).enumerated() where h.insetBy(dx: -3, dy: -3).contains(p) { return i } + return nil + } + + func select(_ card: CardView?) { + if selectedCardView !== card { unfocusCard(commit: true) } + selectedCardView = card + overlay.needsDisplay = true + onSelectionChange?() + } + + // MARK: focus + + func focusCard(_ card: CardView) { + guard card.kind == .mainText || card.kind == .text, let tv = card.textView else { return } + select(card) + raise(card) + card.isFocused = true + tv.isEditable = true + tv.isSelectable = true + window?.makeFirstResponder(tv) + card.needsDisplay = true + overlay.needsDisplay = true + if let scroll = enclosingScrollView { zoom(scroll, to: card.frame) } + } + + @discardableResult + func unfocusCard(commit: Bool) -> Bool { + guard let card = cardViews.first(where: { $0.isFocused }) else { return false } + card.textView?.breakUndoCoalescing() + if commit, card.kind == .text, let idx = cards.firstIndex(where: { $0.id == card.cardID }) { + cards[idx].text = card.textView?.string + markDirty() + } + if card.kind == .text { card.textView?.isEditable = false; card.textView?.isSelectable = false } + else { card.textView?.isEditable = false; card.textView?.isSelectable = false } + card.isFocused = false + card.needsDisplay = true + window?.makeFirstResponder(self) + overlay.needsDisplay = true + return true + } + + private func zoom(_ scroll: NSScrollView, to rect: NSRect) { + let target = min(1.4, max(1.0, (scroll.contentView.bounds.width - 80) / max(1, rect.width))) + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.25 + ctx.allowsImplicitAnimation = true + scroll.animator().setMagnification(target, centeredAt: NSPoint(x: rect.midX, y: rect.midY)) + } + } + + private func raise(_ card: CardView) { + guard let idx = cardViews.firstIndex(where: { $0 === card }) else { return } + cardViews.remove(at: idx) + cardViews.append(card) + card.removeFromSuperview() + addSubview(card, positioned: .below, relativeTo: overlay) + syncModelOrder() + } + + private func syncModelOrder() { + var reordered: [BoardCard] = [] + for v in cardViews { + if let card = cards.first(where: { $0.id == v.cardID }) { reordered.append(card) } + } + cards = reordered + } + + // MARK: mutation helpers + + func markDirty() { + onChange?() + document?.updateChangeCount(.changeDone) + } + + private func updateModelFrame(_ id: UUID, _ frame: NSRect) { + if let i = cards.firstIndex(where: { $0.id == id }) { cards[i].frame = frame } + } + + func addBox(_ rect: NSRect) { + let card = BoardCard(kind: .box, frame: rect, z: cards.count, + fillHex: boxFill.withAlphaComponent(0.18).hexString, + strokeHex: boxStroke.hexString, cornerRadius: 8) + addCard(card, undoName: "Add Box") + } + + func addTextCard(at center: NSPoint) { + // A new text "doc" matches the current page size / orientation; the user + // can resize it freely afterward. + let ps = mainPageSize, o = mainOrientation + let size = ps.size(o) + // Place it beside existing content (not on top of the main page) so pages + // never overlap; fall back to the given center on an empty board. + let origin: NSPoint + if cardViews.isEmpty { + origin = NSPoint(x: center.x - size.width / 2, y: center.y - size.height / 2) + } else { + let content = contentBounds() + origin = NSPoint(x: content.maxX + 48, y: content.minY) + } + let rect = NSRect(origin: origin, size: size) + let card = BoardCard(kind: .text, frame: rect, z: cards.count, text: "", + orientation: o, pageSize: ps.rawValue) + addCard(card, undoName: "Add Text Page") + scrollToVisible(rect.insetBy(dx: -60, dy: -60)) // reveal the new page + } + + func addImageCard(_ image: NSImage, at center: NSPoint) { + var size = image.size + let maxDim: CGFloat = 360 + if size.width > maxDim || size.height > maxDim { + let s = maxDim / max(size.width, size.height); size = NSSize(width: size.width * s, height: size.height * s) + } + if size.width < 10 || size.height < 10 { size = NSSize(width: 240, height: 180) } + let id = UUID() + let rect = NSRect(x: center.x - size.width / 2, y: center.y - size.height / 2, + width: size.width, height: size.height) + let card = BoardCard(id: id, kind: .image, frame: rect, z: cards.count, imageFile: "img-\(id).png") + let view = addCard(card, undoName: "Add Image") + view?.setImage(image) + } + + func setImage(_ image: NSImage, forCardID id: UUID) { + cardViews.first { $0.cardID == id }?.setImage(image) + } + + func image(forCardID id: UUID) -> NSImage? { + cardViews.first { $0.cardID == id }?.imageView?.image + } + + @discardableResult + private func addCard(_ card: BoardCard, undoName: String) -> CardView? { + let v = CardView(card: card, board: self) + cards.append(card) + cardViews.append(v) + addSubview(v, positioned: .below, relativeTo: overlay) + boardUndo.registerUndo(withTarget: self) { b in b.extract(v, card: card) } + boardUndo.setActionName(undoName) + select(v) + markDirty() + return v + } + + func removeCard(id: UUID, undoName: String = "Delete") { + guard let idx = cardViews.firstIndex(where: { $0.cardID == id }), + cardViews[idx].kind != .mainText, + let card = cards.first(where: { $0.id == id }) else { return } + boardUndo.setActionName(undoName) + extract(cardViews[idx], card: card) + } + + /// Remove a card view, undoably. Reuses the LIVE view (preserving its image + /// pixels / text) instead of rebuilding a blank one from the model, and + /// registers a symmetric redo so delete↔undo↔redo all round-trip. + private func extract(_ view: CardView, card: BoardCard) { + if selectedCardView === view { select(nil) } + view.removeFromSuperview() + cardViews.removeAll { $0 === view } + cards.removeAll { $0.id == card.id } + boardUndo.registerUndo(withTarget: self) { b in b.reinsert(view, card: card) } + markDirty() + } + + private func reinsert(_ view: CardView, card: BoardCard) { + cards.append(card) + cardViews.append(view) + addSubview(view, positioned: .below, relativeTo: overlay) + boardUndo.registerUndo(withTarget: self) { b in b.extract(view, card: card) } + select(view) + markDirty() + } + + func deleteSelection() { + if let c = selectedCardView { removeCard(id: c.cardID, undoName: "Delete Card") } + } + + func undo() { if boardUndo.canUndo { boardUndo.undo(); overlay.needsDisplay = true } } + func redo() { if boardUndo.canRedo { boardUndo.redo(); overlay.needsDisplay = true } } + + // MARK: orientation + framing + + var mainOrientation: PageOrientation { cards.first { $0.kind == .mainText }?.orientation ?? .portrait } + var mainPageSize: PageSize { PageSize.from(cards.first { $0.kind == .mainText }?.pageSize) } + + func setMainOrientation(_ o: PageOrientation) { + applyMainPage(size: mainPageSize, orientation: o) + } + + func setMainPageSize(_ size: PageSize) { + applyMainPage(size: size, orientation: mainOrientation) + } + + /// Resize the main page card to a preset size × orientation, keeping its + /// top-left corner fixed. + private func applyMainPage(size: PageSize, orientation: PageOrientation) { + guard let i = cards.firstIndex(where: { $0.kind == .mainText }) else { return } + let dims = size.size(orientation) + cards[i].orientation = orientation + cards[i].pageSize = size.rawValue + cards[i].frame.size = dims + mainCardView?.frame.size = dims + overlay.needsDisplay = true + markDirty() + } + + /// Union of all card frames (the meaningful content), for fit + export. + func contentBounds() -> NSRect { + var rects = cardViews.map { $0.frame } + if let sb = shapes.contentBounds() { rects.append(sb) } + guard var u = rects.first else { + let p = PageSize.letter.size(.portrait) + return NSRect(x: (BoardView.worldSize.width - p.width) / 2, y: 200, width: p.width, height: p.height) + } + for r in rects.dropFirst() { u = u.union(r) } + return u + } + + func zoomToFit() { + guard let scroll = enclosingScrollView else { return } + let content = contentBounds().insetBy(dx: -80, dy: -80) + let vp = scroll.contentView.bounds.size + let scale = min(scroll.maxMagnification, max(scroll.minMagnification, + min(vp.width / max(1, content.width), vp.height / max(1, content.height)))) + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.25 + ctx.allowsImplicitAnimation = true + scroll.animator().setMagnification(scale, centeredAt: NSPoint(x: content.midX, y: content.midY)) + } + DispatchQueue.main.async { + self.scrollToVisible(content) + } + } + + /// Center the viewport on the main card without changing magnification. + func centerOnMain() { + guard let main = mainCardView else { return } + scrollToVisible(main.frame.insetBy(dx: -60, dy: -60)) + } + + // MARK: mouse — select / drag / resize / box + + private enum DragMode { case none, move, resize(Int), box } + private var dragMode: DragMode = .none + private var dragStart = NSPoint.zero + private var origFrame = NSRect.zero + + override func mouseDown(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + if tool == .box { + dragMode = .box + dragStart = p + liveBox = NSRect(origin: p, size: .zero) + return + } + // select tool + if let sel = selectedCardView, let h = handleIndex(at: p, for: sel.frame) { + dragMode = .resize(h); dragStart = p; origFrame = sel.frame + return + } + if let hitView = topCard(at: p) { + select(hitView) + if event.clickCount >= 2 { focusCard(hitView); dragMode = .none; return } + dragMode = .move; dragStart = p; origFrame = hitView.frame + } else { + select(nil) + unfocusCard(commit: true) + dragMode = .none + } + } + + override func mouseDragged(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + switch dragMode { + case .move: + guard let v = selectedCardView else { return } + var f = origFrame + f.origin.x += p.x - dragStart.x + f.origin.y += p.y - dragStart.y + v.frame = snap(f) + overlay.needsDisplay = true + case .resize(let h): + guard let v = selectedCardView else { return } + v.frame = snap(resized(origFrame, handle: h, by: NSPoint(x: p.x - dragStart.x, y: p.y - dragStart.y))) + overlay.needsDisplay = true + case .box: + liveBox = NSRect(corner: dragStart, to: p) + overlay.needsDisplay = true + case .none: + break + } + } + + override func mouseUp(with event: NSEvent) { + switch dragMode { + case .move, .resize: + if let v = selectedCardView { + updateModelFrame(v.cardID, v.frame) + markDirty() + } + case .box: + if let b = liveBox, b.width > 8, b.height > 8 { addBox(b) } + liveBox = nil + overlay.needsDisplay = true + case .none: + break + } + dragMode = .none + } + + private func topCard(at p: NSPoint) -> CardView? { + for v in cardViews.reversed() where v.frame.contains(p) { return v } + return nil + } + + private func snap(_ r: NSRect) -> NSRect { + guard AppSettings.boardSnap else { + return NSRect(x: r.origin.x, y: r.origin.y, width: max(40, r.width), height: max(30, r.height)) + } + let g: CGFloat = 8 + return NSRect(x: (r.origin.x / g).rounded() * g, y: (r.origin.y / g).rounded() * g, + width: max(40, (r.width / g).rounded() * g), height: max(30, (r.height / g).rounded() * g)) + } + + private func resized(_ r: NSRect, handle: Int, by d: NSPoint) -> NSRect { + // handle order: 0 TL,1 TM,2 TR,3 ML,4 MR,5 BL,6 BM,7 BR + let minW: CGFloat = 40, minH: CGFloat = 30 + var minX = r.minX, maxX = r.maxX, minY = r.minY, maxY = r.maxY + let left = [0, 3, 5].contains(handle), right = [2, 4, 7].contains(handle) + let top = [0, 1, 2].contains(handle), bottom = [5, 6, 7].contains(handle) + // Move the dragged edge but never past the anchored (opposite) edge — + // clamp against min size so the card can't flip or explode. + if left { minX = min(r.minX + d.x, maxX - minW) } + if right { maxX = max(r.maxX + d.x, minX + minW) } + if top { minY = min(r.minY + d.y, maxY - minH) } + if bottom { maxY = max(r.maxY + d.y, minY + minH) } + return NSRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + } + + override func keyDown(with event: NSEvent) { + switch event.keyCode { + case 53: // esc + if !unfocusCard(commit: true) { select(nil) } + case 51, 117: // delete / fwd-delete + deleteSelection() + case 36: // return → focus selected text card + if let c = selectedCardView, c.kind == .mainText || c.kind == .text { focusCard(c) } + default: + super.keyDown(with: event) + } + } + + // MARK: right-click context menu + + override func menu(for event: NSEvent) -> NSMenu? { + let p = convert(event.locationInWindow, from: nil) + let card = topCard(at: p) + select(card) // right-click selects what's under the cursor + return container?.contextMenu(forCard: card) + } + + // MARK: z-order + duplicate (used by the context menu) + + func bringToFront(_ card: CardView) { + guard let idx = cardViews.firstIndex(where: { $0 === card }) else { return } + cardViews.remove(at: idx) + cardViews.append(card) + card.removeFromSuperview() + addSubview(card, positioned: .below, relativeTo: overlay) + syncModelOrder(); markDirty(); overlay.needsDisplay = true + } + + func sendToBack(_ card: CardView) { + guard let idx = cardViews.firstIndex(where: { $0 === card }) else { return } + cardViews.remove(at: idx) + cardViews.insert(card, at: 0) + card.removeFromSuperview() + addSubview(card, positioned: .above, relativeTo: shapes) + syncModelOrder(); markDirty(); overlay.needsDisplay = true + } + + func duplicateSelected() { + guard let v = selectedCardView, v.kind != .mainText, + let card = cards.first(where: { $0.id == v.cardID }) else { return } + var copy = card + copy.id = UUID() + copy.frame = card.frame.offsetBy(dx: 28, dy: 28) + copy.z = cards.count + if copy.kind == .image { copy.imageFile = "img-\(copy.id).png" } + let view = addCard(copy, undoName: "Duplicate") + if copy.kind == .image, let img = v.imageView?.image { view?.setImage(img) } + } + + func focusSelected() { + if let c = selectedCardView, c.kind == .mainText || c.kind == .text { focusCard(c) } + } +} + +// MARK: - Board scroll view (modifier-aware wheel) + +/// Wheel behavior in board mode: ⌃ zooms in/out centered on the cursor, +/// ⌥ scrolls horizontally, ⌘ scrolls vertically; a plain wheel keeps the +/// default scrolling. Pan amounts are divided by magnification so the felt +/// speed stays constant at any zoom. +final class BoardScrollView: NSScrollView { + override func scrollWheel(with event: NSEvent) { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let precise = event.hasPreciseScrollingDeltas + let dy = precise ? event.scrollingDeltaY : event.deltaY * 24 + let dx = precise ? event.scrollingDeltaX : event.deltaX * 24 + + if flags.contains(.control) { + let raw = precise ? event.scrollingDeltaY : event.deltaY + guard raw != 0 else { return } + let factor = 1 + raw * (precise ? 0.004 : 0.05) + let newMag = max(minMagnification, min(maxMagnification, magnification * factor)) + if let doc = documentView { + setMagnification(newMag, centeredAt: doc.convert(event.locationInWindow, from: nil)) + } + return + } + if flags.contains(.option) { + // Map the (usually vertical) wheel onto the X axis. + pan(dx: -(dy != 0 ? dy : dx), dy: 0) + return + } + if flags.contains(.command) { + pan(dx: 0, dy: -dy) + return + } + super.scrollWheel(with: event) + } + + private func pan(dx: CGFloat, dy: CGFloat) { + let m = max(magnification, 0.0001) + let clip = contentView + var origin = clip.bounds.origin + origin.x += dx / m + origin.y += dy / m + let constrained = clip.constrainBoundsRect(NSRect(origin: origin, size: clip.bounds.size)) + clip.scroll(to: constrained.origin) + reflectScrolledClipView(clip) + } +} + +// MARK: - Board container (palette + magnifying scroll view) + +/// Hosts the freeform board: a fixed left tool palette plus a magnifying scroll +/// view whose document view is the BoardView world. Mirrors CanvasContainerView's +/// palette idiom and absorbs the old paint canvas (the board's shapes layer). +final class BoardContainerView: NSView { + let boardView = BoardView(frame: .zero) + let boardScroll = BoardScrollView() + private let palette = NSStackView() + private var theme: EditorTheme = .system + + weak var document: Document? { + didSet { boardView.document = document } + } + var onStatus: ((String) -> Void)? + var onSendToTailnet: ((Data) -> Void)? + var onStatusChange: (() -> Void)? // pan/zoom/tool/selection → refresh status bar + + var currentTool: BoardTool { tool } + var zoomPercent: Int { Int((boardScroll.magnification * 100).rounded()) } + + private var tool: BoardTool = .select + private var toolButtons: [BoardTool: NSButton] = [:] + private var strokeWell: NSColorWell? + private var orientationSeg: NSSegmentedControl? + private var pageSizeButton: NSButton? + + private let paletteWidth: CGFloat = 60 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + required init?(coder: NSCoder) { fatalError() } + deinit { NotificationCenter.default.removeObserver(self) } + + @objc private func boardViewportChanged() { onStatusChange?() } + + private func setup() { + boardView.container = self + boardScroll.translatesAutoresizingMaskIntoConstraints = false + boardScroll.hasVerticalScroller = true + boardScroll.hasHorizontalScroller = true + boardScroll.borderType = .noBorder + boardScroll.allowsMagnification = true + boardScroll.minMagnification = 0.15 + boardScroll.maxMagnification = 4 + boardScroll.documentView = boardView + addSubview(boardScroll) + // Status updates on pan/zoom. + boardScroll.contentView.postsBoundsChangedNotifications = true + NotificationCenter.default.addObserver(self, selector: #selector(boardViewportChanged), + name: NSView.boundsDidChangeNotification, + object: boardScroll.contentView) + + palette.orientation = .vertical + palette.alignment = .centerX + palette.spacing = 5 + palette.edgeInsets = NSEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) + palette.translatesAutoresizingMaskIntoConstraints = false + palette.wantsLayer = true + addSubview(palette) + + buildPalette() + + NSLayoutConstraint.activate([ + palette.leadingAnchor.constraint(equalTo: leadingAnchor), + palette.topAnchor.constraint(equalTo: topAnchor), + palette.bottomAnchor.constraint(equalTo: bottomAnchor), + palette.widthAnchor.constraint(equalToConstant: paletteWidth), + boardScroll.leadingAnchor.constraint(equalTo: palette.trailingAnchor), + boardScroll.trailingAnchor.constraint(equalTo: trailingAnchor), + boardScroll.topAnchor.constraint(equalTo: topAnchor), + boardScroll.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + boardView.onSelectionChange = { [weak self] in self?.refreshPageControls(); self?.onStatusChange?() } + applyColor(strokeWell?.color ?? .systemBlue) // sync pen to the well at launch + selectTool(.select) + apply(theme: .system) + } + + private let toolOrder: [(BoardTool, String, String)] = [ + (.select, "cursorarrow", "Select / move / resize"), + (.box, "rectangle", "Clean box"), + (.pen, "pencil.tip", "Pen"), + (.marker, "highlighter", "Marker"), + (.line, "line.diagonal", "Line"), + (.arrow, "arrow.up.right", "Arrow"), + (.oval, "oval", "Oval"), + (.eraser, "eraser", "Eraser"), + ] + + private func buildPalette() { + for (t, sym, tip) in toolOrder { + let b = paletteButton(symbol: sym, tooltip: tip) + b.target = self; b.action = #selector(toolTapped(_:)) + b.tag = toolOrder.firstIndex { $0.0 == t }! + toolButtons[t] = b + palette.addArrangedSubview(b) + } + + palette.addArrangedSubview(separator()) + let stroke = NSColorWell() + stroke.color = .systemBlue + stroke.target = self; stroke.action = #selector(strokeChanged(_:)) + stroke.toolTip = "Color for pen, shapes & boxes — click to pick" + constrainSquare(stroke, 30) + strokeWell = stroke + palette.addArrangedSubview(stroke) + let eyedrop = paletteButton(symbol: "eyedropper", tooltip: "Grab a color from anywhere on screen") + eyedrop.target = self; eyedrop.action = #selector(pickScreenColor) + palette.addArrangedSubview(eyedrop) + let width = NSSlider(value: 3, minValue: 1, maxValue: 24, target: self, action: #selector(widthChanged(_:))) + width.toolTip = "Stroke width" + width.translatesAutoresizingMaskIntoConstraints = false + width.widthAnchor.constraint(equalToConstant: 48).isActive = true + palette.addArrangedSubview(width) + + palette.addArrangedSubview(separator()) + let newText = paletteButton(symbol: "doc.badge.plus", tooltip: "New text page (current page size)") + newText.target = self; newText.action = #selector(newTextCard); palette.addArrangedSubview(newText) + let addImage = paletteButton(symbol: "photo", tooltip: "Add image…") + addImage.target = self; addImage.action = #selector(addImage(_:)); palette.addArrangedSubview(addImage) + + let pageBtn = paletteButton(symbol: "ruler", tooltip: "Main page size") + pageBtn.target = self; pageBtn.action = #selector(showPageSizeMenu(_:)) + pageSizeButton = pageBtn + palette.addArrangedSubview(pageBtn) + + let orient = NSSegmentedControl(labels: ["P", "L"], trackingMode: .selectOne, + target: self, action: #selector(orientationChanged(_:))) + orient.toolTip = "Portrait / Landscape page" + orient.selectedSegment = 0 + orient.translatesAutoresizingMaskIntoConstraints = false + orient.widthAnchor.constraint(equalToConstant: 48).isActive = true + orientationSeg = orient + palette.addArrangedSubview(orient) + + palette.addArrangedSubview(separator()) + let fit = paletteButton(symbol: "arrow.up.left.and.down.right.magnifyingglass", tooltip: "Zoom to fit") + fit.target = self; fit.action = #selector(zoomFit); palette.addArrangedSubview(fit) + let undo = paletteButton(symbol: "arrow.uturn.backward", tooltip: "Undo board change") + undo.target = self; undo.action = #selector(undoTapped); palette.addArrangedSubview(undo) + let redo = paletteButton(symbol: "arrow.uturn.forward", tooltip: "Redo board change") + redo.target = self; redo.action = #selector(redoTapped); palette.addArrangedSubview(redo) + + palette.addArrangedSubview(separator()) + let export = paletteButton(symbol: "square.and.arrow.down", tooltip: "Export board PNG…") + export.target = self; export.action = #selector(exportTapped); palette.addArrangedSubview(export) + let send = paletteButton(symbol: "paperplane", tooltip: "Send board to tailnet") + send.target = self; send.action = #selector(sendTapped); palette.addArrangedSubview(send) + } + + private func paletteButton(symbol: String, tooltip: String) -> NSButton { + let b = NSButton() + b.bezelStyle = .regularSquare + b.isBordered = true + b.setButtonType(.momentaryPushIn) + b.image = NSImage(systemSymbolName: symbol, accessibilityDescription: tooltip) + b.imageScaling = .scaleProportionallyDown + b.toolTip = tooltip + constrainSquare(b, 30) + return b + } + + private func separator() -> NSView { + let v = NSBox(); v.boxType = .separator + v.translatesAutoresizingMaskIntoConstraints = false + v.widthAnchor.constraint(equalToConstant: 42).isActive = true + return v + } + + private func constrainSquare(_ v: NSView, _ side: CGFloat) { + v.translatesAutoresizingMaskIntoConstraints = false + v.widthAnchor.constraint(equalToConstant: side).isActive = true + v.heightAnchor.constraint(equalToConstant: side).isActive = true + } + + // MARK: actions + + @objc private func toolTapped(_ sender: NSButton) { + selectTool(toolOrder[sender.tag].0) + } + + func selectTool(_ t: BoardTool) { + tool = t + boardView.tool = t + for (bt, b) in toolButtons { + b.contentTintColor = (bt == t) ? theme.accent : nil + b.layer?.backgroundColor = (bt == t) ? theme.accent.withAlphaComponent(0.16).cgColor : nil + } + onStatusChange?() + } + + @objc private func strokeChanged(_ s: NSColorWell) { + applyColor(s.color) + } + + /// Single source of truth for the active color — drives the well, the box + /// fill/stroke, and the freehand pen/shape color together. + func applyColor(_ color: NSColor) { + strokeWell?.color = color + boardView.boxFill = color + boardView.boxStroke = color + boardView.applyDrawStyle(color: color, width: boardView.shapes.lineWidth, fill: boardView.shapes.fillEnabled) + } + + /// Native macOS screen color sampler — click anywhere on screen (the loupe) + /// to grab a color and apply it to pen, shapes, and boxes. + @objc private func pickScreenColor() { + NSColorSampler().show { [weak self] picked in + guard let self, let picked else { return } + self.applyColor(picked) + } + } + @objc private func widthChanged(_ s: NSSlider) { + boardView.applyDrawStyle(color: boardView.shapes.strokeColor, width: CGFloat(s.doubleValue), fill: boardView.shapes.fillEnabled) + } + @objc private func newTextCard() { + boardView.addTextCard(at: viewportCenterInWorld()) + selectTool(.select) + } + @objc private func orientationChanged(_ s: NSSegmentedControl) { + boardView.setMainOrientation(s.selectedSegment == 1 ? .landscape : .portrait) + } + @objc private func showPageSizeMenu(_ sender: NSButton) { + let menu = NSMenu() + let current = boardView.mainPageSize + for ps in PageSize.allCases { + let item = NSMenuItem(title: ps.label, action: #selector(pageSizePicked(_:)), keyEquivalent: "") + item.target = self + item.representedObject = ps.rawValue + item.state = (ps == current) ? .on : .off + menu.addItem(item) + } + menu.popUp(positioning: nil, at: NSPoint(x: sender.bounds.maxX, y: sender.bounds.midY), in: sender) + } + + @objc private func pageSizePicked(_ item: NSMenuItem) { + guard let raw = item.representedObject as? String else { return } + boardView.setMainPageSize(PageSize.from(raw)) + refreshPageControls() + } + + /// Sync the orientation control + page-size tooltip to the loaded main page. + func refreshPageControls() { + orientationSeg?.selectedSegment = boardView.mainOrientation == .landscape ? 1 : 0 + pageSizeButton?.toolTip = "Main page size — \(boardView.mainPageSize.label)" + } + @objc private func zoomFit() { boardView.zoomToFit(); onStatusChange?() } + @objc private func undoTapped() { boardView.undo() } + @objc private func redoTapped() { boardView.redo() } + + @objc private func zoomIn() { setZoom(boardScroll.magnification * 1.25) } + @objc private func zoomOut() { setZoom(boardScroll.magnification / 1.25) } + @objc private func actualSize() { setZoom(1) } + private func setZoom(_ m: CGFloat) { + let clamped = max(boardScroll.minMagnification, min(boardScroll.maxMagnification, m)) + boardScroll.setMagnification(clamped, centeredAt: boardView.viewportCenter()) + onStatusChange?() + } + + // MARK: - Right-click context menu (full feature access from the mouse) + + private let colorSwatches: [(String, NSColor)] = [ + ("Red", .systemRed), ("Orange", .systemOrange), ("Yellow", .systemYellow), + ("Green", .systemGreen), ("Blue", .systemBlue), ("Purple", .systemPurple), + ("Pink", .systemPink), ("Black", .black), ("Gray", .systemGray), ("White", .white), + ] + + func contextMenu(forCard card: CardView?) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + if let card { addCardSection(menu, card: card); menu.addItem(.separator()) } + addBoardSection(menu) + return menu + } + + private func mi(_ title: String, _ action: Selector?, obj: Any? = nil, + checked: Bool = false, enabled: Bool = true) -> NSMenuItem { + let it = NSMenuItem(title: title, action: action, keyEquivalent: "") + it.target = self + it.representedObject = obj + it.state = checked ? .on : .off + it.isEnabled = enabled + return it + } + + private func submenu(_ title: String, _ build: (NSMenu) -> Void) -> NSMenuItem { + let parent = NSMenuItem(title: title, action: nil, keyEquivalent: "") + let sub = NSMenu(title: title) + sub.autoenablesItems = false + build(sub) + parent.submenu = sub + return parent + } + + private func addCardSection(_ menu: NSMenu, card: CardView) { + let kindName: String + switch card.kind { + case .mainText: kindName = "Main page" + case .text: kindName = "Text page" + case .image: kindName = "Image" + case .box: kindName = "Box" + } + menu.addItem(mi(kindName, nil, enabled: false)) + if card.kind == .mainText || card.kind == .text { + menu.addItem(mi("Edit Text", #selector(menuFocus))) + } + menu.addItem(.separator()) + menu.addItem(mi("Bring to Front", #selector(menuBringFront))) + menu.addItem(mi("Send to Back", #selector(menuSendBack))) + if card.kind != .mainText { + menu.addItem(mi("Duplicate", #selector(menuDuplicate))) + menu.addItem(mi("Delete", #selector(menuDelete))) + } + } + + private func addBoardSection(_ menu: NSMenu) { + menu.addItem(submenu("Tools") { sub in + for t in BoardTool.ordered { + sub.addItem(self.mi(t.name, #selector(self.menuTool(_:)), obj: t, checked: t == self.tool)) + } + }) + menu.addItem(.separator()) + menu.addItem(mi("New Text Page", #selector(newTextCard))) + menu.addItem(mi("Add Image…", #selector(addImage(_:)))) + let hasImg = NSPasteboard.general.canReadObject(forClasses: [NSImage.self], options: nil) + menu.addItem(mi("Paste Image", #selector(menuPaste), enabled: hasImg)) + menu.addItem(.separator()) + menu.addItem(submenu("Page") { sub in + sub.addItem(self.submenu("Size") { s2 in + for ps in PageSize.allCases { + s2.addItem(self.mi(ps.label, #selector(self.menuPageSize(_:)), obj: ps.rawValue, + checked: ps == self.boardView.mainPageSize)) + } + }) + sub.addItem(self.submenu("Orientation") { s2 in + s2.addItem(self.mi("Portrait", #selector(self.menuOrientation(_:)), obj: "portrait", + checked: self.boardView.mainOrientation == .portrait)) + s2.addItem(self.mi("Landscape", #selector(self.menuOrientation(_:)), obj: "landscape", + checked: self.boardView.mainOrientation == .landscape)) + }) + }) + menu.addItem(submenu("Zoom") { sub in + sub.addItem(self.mi("Zoom In", #selector(self.zoomIn))) + sub.addItem(self.mi("Zoom Out", #selector(self.zoomOut))) + sub.addItem(self.mi("Zoom to Fit", #selector(self.zoomFit))) + sub.addItem(self.mi("Actual Size (100%)", #selector(self.actualSize))) + }) + menu.addItem(submenu("Color") { sub in + for (name, color) in self.colorSwatches { + sub.addItem(self.mi(name, #selector(self.menuColor(_:)), obj: color)) + } + sub.addItem(.separator()) + sub.addItem(self.mi("Pick from Screen…", #selector(self.pickScreenColor))) + sub.addItem(self.mi("Show Color Picker…", #selector(self.menuShowColors))) + }) + menu.addItem(submenu("View Mode") { sub in + sub.addItem(self.mi("Editor", #selector(self.menuModeEditor))) + sub.addItem(self.mi("Editor + Preview", #selector(self.menuModeSplit))) + sub.addItem(self.mi("Board", #selector(self.menuModeBoard), checked: true)) + }) + menu.addItem(submenu("Theme") { sub in + let cur = AppSettings.theme.id + for th in EditorTheme.all { + sub.addItem(self.mi(th.name, #selector(self.menuTheme(_:)), obj: th.id, checked: th.id == cur)) + } + }) + menu.addItem(.separator()) + menu.addItem(mi("Undo", #selector(undoTapped))) + menu.addItem(mi("Redo", #selector(redoTapped))) + menu.addItem(.separator()) + menu.addItem(mi("Export Board PNG…", #selector(exportTapped))) + menu.addItem(mi("Send Board to Tailnet", #selector(sendTapped))) + } + + @objc private func menuTool(_ s: NSMenuItem) { if let t = s.representedObject as? BoardTool { selectTool(t) } } + @objc private func menuPaste() { boardView.paste(nil) } + @objc private func menuPageSize(_ s: NSMenuItem) { + if let raw = s.representedObject as? String { boardView.setMainPageSize(PageSize.from(raw)); refreshPageControls() } + } + @objc private func menuOrientation(_ s: NSMenuItem) { + if let raw = s.representedObject as? String, let o = PageOrientation(rawValue: raw) { + boardView.setMainOrientation(o); refreshPageControls() + } + } + @objc private func menuColor(_ s: NSMenuItem) { if let c = s.representedObject as? NSColor { applyColor(c) } } + @objc private func menuShowColors() { strokeWell?.activate(true) } + @objc private func menuFocus() { boardView.focusSelected() } + @objc private func menuBringFront() { if let c = boardView.selectedCardView { boardView.bringToFront(c) } } + @objc private func menuSendBack() { if let c = boardView.selectedCardView { boardView.sendToBack(c) } } + @objc private func menuDuplicate() { boardView.duplicateSelected() } + @objc private func menuDelete() { boardView.deleteSelection() } + @objc private func menuModeEditor() { document?.showEditorMode(nil) } + @objc private func menuModeSplit() { document?.showSplitMode(nil) } + @objc private func menuModeBoard() { document?.showBoardMode(nil) } + @objc private func menuTheme(_ s: NSMenuItem) { if let id = s.representedObject as? String { AppSettings.set(AppSettings.themeKey, id) } } + + @objc private func addImage(_ sender: Any?) { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.png, .jpeg, .tiff, .gif, .heic, .image] + panel.allowsMultipleSelection = false + let complete: (NSApplication.ModalResponse) -> Void = { [weak self] resp in + guard resp == .OK, let url = panel.url, let self else { return } + self.document?.importImageToBoard(url: url, at: self.viewportCenterInWorld()) + } + if let w = window { panel.beginSheetModal(for: w, completionHandler: complete) } + else { complete(panel.runModal()) } + } + + @objc private func exportTapped() { + guard let data = boardPNG() else { onStatus?("Couldn't render board"); return } + let panel = NSSavePanel() + panel.nameFieldStringValue = "board.png" + panel.allowedContentTypes = [.png] + let complete: (NSApplication.ModalResponse) -> Void = { resp in + guard resp == .OK, let url = panel.url else { return } + do { try data.write(to: url); self.onStatus?("Exported \(url.lastPathComponent)") } + catch { self.onStatus?("Export failed: \(error.localizedDescription)") } + } + if let w = window { panel.beginSheetModal(for: w, completionHandler: complete) } + else { complete(panel.runModal()) } + } + @objc private func sendTapped() { + guard let data = boardPNG() else { onStatus?("Couldn't render board"); return } + onSendToTailnet?(data) + } + + /// Render the content region of the board to PNG at 1x (not the zoomed view, + /// not the whole 8000×6000 world). + func boardPNG() -> Data? { + boardView.unfocusCard(commit: true) + let region = boardView.contentBounds().insetBy(dx: -20, dy: -20).integral + guard region.width > 1, region.height > 1, + let rep = boardView.bitmapImageRepForCachingDisplay(in: region) else { return nil } + boardView.cacheDisplay(in: region, to: rep) + return rep.representation(using: .png, properties: [:]) + } + + private func viewportCenterInWorld() -> NSPoint { + let v = boardScroll.contentView.bounds + return NSPoint(x: v.midX, y: v.midY) + } + + func apply(theme: EditorTheme) { + self.theme = theme + palette.layer?.backgroundColor = theme.gutterBackground.cgColor + boardScroll.backgroundColor = theme.isDark ? EditorTheme.rgb(26, 28, 34) : EditorTheme.rgb(228, 229, 234) + boardScroll.drawsBackground = true + boardView.apply(theme: theme) + selectTool(tool) + refreshPageControls() + } +} diff --git a/Sources/Document.swift b/Sources/Document.swift new file mode 100644 index 0000000..bb01686 --- /dev/null +++ b/Sources/Document.swift @@ -0,0 +1,556 @@ +import AppKit + +final class Document: NSDocument, NSTextViewDelegate { + private var text = "" + private weak var textView: NSTextView? + private(set) var workspace: WorkspaceController? + // Carries "the user picked Sync Now" intent across the save that a dirty + // or untitled document needs before it can sync. + private var pendingManualSync = false + // Lets a newer sync's status win over a slower, older one's completion. + private var syncGeneration = 0 + + override class var autosavesInPlace: Bool { false } + + // MARK: - Window + + override func makeWindowControllers() { + let scrollView = NSTextView.scrollableTextView() + guard let tv = scrollView.documentView as? NSTextView else { return } + configure(textView: tv) + tv.string = text + textView = tv + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 620), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.minSize = NSSize(width: 480, height: 320) + window.center() + + let workspace = WorkspaceController(document: self, editorScrollView: scrollView, textView: tv) + self.workspace = workspace + window.contentView = workspace.containerView + workspace.installToolbar(on: window) + + let controller = NSWindowController(window: window) + controller.shouldCascadeWindows = true + addWindowController(controller) + loadBoardSidecar() + window.makeFirstResponder(tv) + } + + private func configure(textView tv: NSTextView) { + TextViewConfig.harden(tv) + tv.usesFindBar = true + tv.isIncrementalSearchingEnabled = true + tv.font = AppSettings.editorFont + tv.textContainerInset = NSSize(width: 8, height: 10) + tv.delegate = self + } + + // Route the text view's undo stack through the document so the + // dirty dot and change count stay accurate automatically. + func undoManager(for view: NSTextView) -> UndoManager? { + undoManager + } + + // MARK: - Reading / writing + + override func data(ofType typeName: String) throws -> Data { + if let tv = textView { + text = tv.string + } + guard let data = text.data(using: .utf8) else { + throw NSError(domain: NSCocoaErrorDomain, + code: NSFileWriteInapplicableStringEncodingError, + userInfo: [NSLocalizedDescriptionKey: "Couldn't encode the text as UTF-8."]) + } + return data + } + + override func read(from data: Data, ofType typeName: String) throws { + if let utf8 = String(data: data, encoding: .utf8) { + text = utf8 + } else { + var converted: NSString? + var lossy: ObjCBool = false + let encoding = NSString.stringEncoding(for: data, + encodingOptions: nil, + convertedString: &converted, + usedLossyConversion: &lossy) + // Refuse lossy conversions: saving would silently destroy bytes. + guard encoding != 0, let converted, !lossy.boolValue else { + throw NSError(domain: NSCocoaErrorDomain, + code: NSFileReadInapplicableStringEncodingError, + userInfo: [NSLocalizedDescriptionKey: "Couldn't determine the file's text encoding."]) + } + text = converted as String + } + textView?.string = text + workspace?.refreshTextMetrics() // keep the status bar counts current on Revert + } + + // MARK: - Save hook → sync + + override func save(to url: URL, + ofType typeName: String, + for saveOperation: NSDocument.SaveOperationType, + completionHandler: @escaping (Error?) -> Void) { + textView?.breakUndoCoalescing() + super.save(to: url, ofType: typeName, for: saveOperation) { [weak self] error in + completionHandler(error) + guard let self else { return } + let manual = self.pendingManualSync + self.pendingManualSync = false + guard error == nil, + saveOperation == .saveOperation || saveOperation == .saveAsOperation else { return } + self.saveBoardSidecar() + self.syncAfterSave(url: url, manual: manual) + self.autoIngestAfterSave() + } + } + + @objc func syncNow(_ sender: Any?) { + if let url = fileURL, !isDocumentEdited { + syncAfterSave(url: url, manual: true) + return + } + // Dirty or untitled: save first; the save hook picks up the manual flag. + pendingManualSync = true + save(withDelegate: self, + didSave: #selector(document(_:didSave:contextInfo:)), + contextInfo: nil) + } + + // Clears the manual flag when the user cancels the Save panel (the save + // override never runs in that case, so the flag would leak into the next save). + @objc private func document(_ document: NSDocument, didSave: Bool, contextInfo: UnsafeMutableRawPointer?) { + if !didSave { pendingManualSync = false } + } + + private func syncAfterSave(url: URL, manual: Bool) { + guard SyncSettings.enabled || manual else { + syncStatus = "" // sync is off — don't leave a stale "Synced ✓" + refreshSubtitle() + return + } + guard SyncSettings.isConfigured else { + if manual { + presentSyncError("No remote configured. Set one in TextPlus → Settings…") + } else { + syncStatus = "Sync is on, but no remote is configured — see Settings" + refreshSubtitle() + } + return + } + let remoteHost = SyncSettings.remote + syncGeneration += 1 + let generation = syncGeneration + syncStatus = "Syncing to \(remoteHost)…" + refreshSubtitle() + SyncEngine.upload(fileURL: url) { [weak self] result in + guard let self, generation == self.syncGeneration else { return } + switch result { + case .success: + let time = Date().formatted(date: .omitted, time: .shortened) + self.syncStatus = "Synced to \(remoteHost) ✓ \(time)" + case .failure(let error): + let firstLine = error.message.split(separator: "\n").first.map(String.init) ?? "unknown error" + self.syncStatus = "Sync failed — \(firstLine)" + NSApp.requestUserAttention(.informationalRequest) + if manual { self.presentSyncError(error.message) } + } + self.refreshSubtitle() + } + } + + private var syncStatus = "" + private var dbStatus = "" + private var dbGeneration = 0 + private var ingestInFlight = false + + private func refreshSubtitle() { + let subtitle = [syncStatus, dbStatus].filter { !$0.isEmpty }.joined(separator: " · ") + windowControllers.first?.window?.subtitle = subtitle + } + + private func presentSyncError(_ message: String) { + let alert = NSAlert() + alert.messageText = "Sync Failed" + alert.informativeText = message + alert.alertStyle = .warning + if let window = windowControllers.first?.window { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } + + // MARK: - HTML parsing tools + + private var currentText: String { + textView?.string ?? text + } + + private static let lastSelectorKey = "lastExtractSelector" + + @objc func listSelectors(_ sender: Any?) { + let data = HTMLScanner.summarize(html: currentText) + Document.openResults(HTMLScanner.formatSummary(data, sourceName: displayName ?? "document")) + } + + @objc func extractListings(_ sender: Any?) { + let page = PageDetector.detect(in: currentText) ?? DetectedPage(url: nil, html: currentText) + let profile = ScrapeProfile.matching(url: page.url) ?? ScrapeProfile.discogsMarketplace + guard let extraction = HTMLScanner.extractRows(html: page.html, + rowSelector: profile.rowSelector, + fields: profile.fields), + !extraction.rows.isEmpty else { + presentSimpleAlert(title: "No Listings Found", + message: "No \(profile.rowSelector) rows in this document. This works on Discogs marketplace pages (sell/list or a seller's profile).") + return + } + var out = extraction.header.joined(separator: "\t") + "\n" + out += extraction.rows.map { $0.joined(separator: "\t") }.joined(separator: "\n") + "\n" + Document.openResults(out) + } + + @objc func sendToPostgres(_ sender: Any?) { + guard let page = PageDetector.detect(in: currentText) else { + presentSimpleAlert(title: "Not an HTML Page", + message: "Paste inspector HTML — optionally with the page URL as the first line — and try again.") + return + } + runIngest(page: page, manual: true) + } + + private func autoIngestAfterSave() { + guard PGSettings.autoIngest, let page = PageDetector.detect(in: currentText) else { + dbStatus = "" + refreshSubtitle() + return + } + runIngest(page: page, manual: false) + } + + private func runIngest(page: DetectedPage, manual: Bool) { + // One ingest per document at a time: a second overlapping ingest of the + // same page would bypass the content-hash dedup (its check runs before + // the first commits) and double-insert audit rows. + if ingestInFlight { + if manual { + presentSimpleAlert(title: "Ingest In Progress", + message: "This document is already being sent to Postgres. Try again once it finishes.") + } + return + } + ingestInFlight = true + dbGeneration += 1 + let generation = dbGeneration + dbStatus = "DB: parsing…" + refreshSubtitle() + let sourceName = displayName ?? "document" + + func finish(_ status: String, alert: String? = nil) { + ingestInFlight = false + guard generation == dbGeneration else { return } + dbStatus = status + refreshSubtitle() + if let alert { presentSimpleAlert(title: "Postgres Ingest Failed", message: alert) } + } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let summary = HTMLScanner.summarize(html: page.html) + let profile = ScrapeProfile.matching(url: page.url) + var header: [String] = [] + var rows: [[String]] = [] + if let profile, + let extraction = HTMLScanner.extractRows(html: page.html, + rowSelector: profile.rowSelector, + fields: profile.fields) { + header = extraction.header + rows = extraction.rows + } + let ingest = PostgresStore.PageIngest( + url: page.url, + sourceName: sourceName, + contentHash: PostgresStore.sha256(page.html), + elementCount: summary.elementCount, + selectors: summary.entries, + profileName: rows.isEmpty ? nil : profile?.name, + header: header, + rows: rows + ) + DispatchQueue.main.async { + guard let self else { return } + guard generation == self.dbGeneration else { self.ingestInFlight = false; return } + self.dbStatus = "DB: ingesting…" + self.refreshSubtitle() + // `finish` already holds self for the duration of the ingest. + PostgresStore.ingest(ingest, force: manual) { result in + switch result { + case .success(let report): + if report.skippedDuplicate { + finish("DB: unchanged") + } else { + var bits = ["\(report.selectorCount) selectors"] + if report.snapshotCount > 0 { bits.append("\(report.snapshotCount) listings upserted") } + else if report.rowCount > 0 { bits.append("\(report.rowCount) rows") } + if report.skippedRows > 0 { bits.append("\(report.skippedRows) skipped") } + finish("DB ✓ page #\(report.pageId): " + bits.joined(separator: ", ")) + } + case .failure(let error): + let firstLine = error.message.split(separator: "\n").first.map(String.init) ?? "error" + finish("DB failed — \(firstLine)", alert: manual ? error.message : nil) + } + } + } + } + } + + @objc func extractForSelector(_ sender: Any?) { + let alert = NSAlert() + alert.messageText = "Extract Text for Selector" + alert.informativeText = "Pulls the text of every matching element into a new document, one match per line (table cells tab-separated). A trailing @attr adds that attribute as the first column; [attr], [!attr], [attr=v], [attr^=v] filter matches.\n\nExamples: .price tr.shortcut_navigable a.item_description_title@href .item_condition span[!class]" + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 380, height: 24)) + field.stringValue = UserDefaults.standard.string(forKey: Document.lastSelectorKey) ?? "" + field.placeholderString = "tag, .class, #id, or a descendant chain" + alert.accessoryView = field + alert.addButton(withTitle: "Extract") + alert.addButton(withTitle: "Cancel") + alert.window.initialFirstResponder = field + // The field editor swallows Return before the default button sees it; + // wire the field's action to the Extract button so Return submits. + field.target = alert.buttons[0] + field.action = #selector(NSButton.performClick(_:)) + + let runExtract: () -> Void = { [weak self] in + guard let self else { return } + let selector = field.stringValue.trimmingCharacters(in: .whitespaces) + guard !selector.isEmpty else { return } + UserDefaults.standard.set(selector, forKey: Document.lastSelectorKey) + guard let result = HTMLScanner.extractText(html: self.currentText, + selector: selector, + sourceName: self.displayName ?? "document") else { + self.presentSimpleAlert(title: "Bad Selector", + message: "Couldn't parse \"\(selector)\". Use tag, .class, #id, combos like tr.shortcut_navigable, or a space-separated descendant chain.") + return + } + guard result.matchCount > 0 else { + self.presentSimpleAlert(title: "No Matches", + message: "Nothing matched \"\(selector)\". Run Tools → List Div & Class Selectors to see what's in this page.") + return + } + Document.openResults(result.body) + } + + if let window = windowControllers.first?.window { + alert.beginSheetModal(for: window) { response in + guard response == .alertFirstButtonReturn else { return } + runExtract() + } + } else if alert.runModal() == .alertFirstButtonReturn { + runExtract() + } + } + + private static func openResults(_ content: String) { + guard let doc = (try? NSDocumentController.shared.openUntitledDocumentAndDisplay(true)) as? Document else { return } + doc.setContent(content) + } + + private func setContent(_ s: String) { + text = s + textView?.string = s + workspace?.applyAppearance() // theme the freshly inserted results + workspace?.refreshTextMetrics() + updateChangeCount(.changeDone) + } + + private func presentSimpleAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + if let window = windowControllers.first?.window { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } + + // MARK: - Font size (broadcast to every open window via AppSettings) + + @objc func makeTextBigger(_ sender: Any?) { + AppSettings.set(AppSettings.fontSizeKey, Double(min(48, AppSettings.fontSize + 1))) + } + + @objc func makeTextSmaller(_ sender: Any?) { + AppSettings.set(AppSettings.fontSizeKey, Double(max(8, AppSettings.fontSize - 1))) + } + + // MARK: - View mode (menu) + appearance toggles + + @objc func showEditorMode(_ sender: Any?) { workspace?.setMode(.editor) } + @objc func showSplitMode(_ sender: Any?) { workspace?.setMode(.split) } + @objc func showBoardMode(_ sender: Any?) { workspace?.setMode(.board) } + + @objc func toggleSoftWrap(_ sender: Any?) { + AppSettings.set(AppSettings.softWrapKey, !AppSettings.softWrap) + } + + @objc func toggleLineNumbers(_ sender: Any?) { + AppSettings.set(AppSettings.lineNumbersKey, !AppSettings.lineNumbers) + } + + @objc func cycleTheme(_ sender: Any?) { + let all = EditorTheme.all + let idx = all.firstIndex { $0.id == AppSettings.theme.id } ?? 0 + AppSettings.set(AppSettings.themeKey, all[(idx + 1) % all.count].id) + } + + // MARK: - Markdown markup (selection-aware) + + @objc func markupBold(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "**", suffix: "**", placeholder: "bold") } } + @objc func markupItalic(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "*", suffix: "*", placeholder: "italic") } } + @objc func markupCode(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "`", suffix: "`", placeholder: "code") } } + @objc func markupStrikethrough(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "~~", suffix: "~~", placeholder: "strike") } } + @objc func markupHeading(_ sender: Any?) { withTextView { TextMarkup.linePrefix($0, prefix: "# ") } } + @objc func markupList(_ sender: Any?) { withTextView { TextMarkup.linePrefix($0, prefix: "- ") } } + @objc func markupQuote(_ sender: Any?) { withTextView { TextMarkup.linePrefix($0, prefix: "> ") } } + @objc func markupLink(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "[", suffix: "](https://)", placeholder: "label") } } + + private func withTextView(_ body: (NSTextView) -> Void) { + guard let textView else { return } + body(textView) + } + + // MARK: - Board sidecar (foo.txt.board/) + image import + + /// Bundle directory next to the document: foo.txt → foo.txt.board/ + private func boardSidecarDir() -> URL? { + fileURL?.appendingPathExtension("board") + } + + private var legacyCanvasURL: URL? { + fileURL?.appendingPathExtension("canvas.json") + } + + /// Build the board model: from the sidecar if present, else migrate a legacy + /// canvas sidecar's shapes, else a fresh default board. Never throws. + func loadBoardSidecar() { + guard let board = workspace?.boardContainer.boardView else { return } + var doc: BoardDoc? + if let dir = boardSidecarDir(), + let data = try? Data(contentsOf: dir.appendingPathComponent("board.json")), + let decoded = try? JSONDecoder().decode(BoardDoc.self, from: data) { + doc = decoded + } else if let legacy = legacyCanvasURL, + let data = try? Data(contentsOf: legacy), + let canvasDoc = try? JSONDecoder().decode(DrawCanvasView.CanvasDoc.self, from: data) { + // One-way migration of the old paint canvas into the board shapes layer. + var fresh = BoardDoc.makeDefault(worldSize: BoardView.worldSize) + fresh.shapes = canvasDoc + doc = fresh + } + let finalDoc = doc ?? BoardDoc.makeDefault(worldSize: BoardView.worldSize) + board.load(doc: finalDoc) + workspace?.boardContainer.refreshPageControls() + workspace?.bindInitialEditorPage() // main card owns the document text view + // Load image assets by card id. + if let dir = boardSidecarDir() { + for card in finalDoc.cards where card.kind == .image { + let name = card.imageFile ?? "img-\(card.id).png" + if let img = NSImage(contentsOf: dir.appendingPathComponent(name)) { + board.setImage(img, forCardID: card.id) + } + } + } + } + + private func saveBoardSidecar() { + guard let board = workspace?.boardContainer.boardView, let dir = boardSidecarDir() else { return } + let doc = board.snapshotDoc + let fm = FileManager.default + // Don't create a sidecar for an untouched plaintext doc, but ALWAYS keep + // an existing one current — otherwise clearing a board back to empty + // would leave a stale board.json that resurrects the deleted objects. + let used = doc.cards.count > 1 || !doc.shapes.elements.isEmpty + || !(board.mainCardView?.textView?.string.isEmpty ?? true) + guard used || fm.fileExists(atPath: dir.path) else { return } + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + + if let data = try? JSONEncoder.boardEncoder.encode(doc) { + let tmp = dir.appendingPathComponent("board.json.tmp") + let dest = dir.appendingPathComponent("board.json") + if (try? data.write(to: tmp)) != nil { + _ = try? fm.replaceItemAt(dest, withItemAt: tmp) + } + } + // Write image assets; collect kept filenames for orphan GC. + var kept = Set(["board.json", "preview.png"]) + for card in doc.cards where card.kind == .image { + let name = card.imageFile ?? "img-\(card.id).png" + kept.insert(name) + if let img = board.image(forCardID: card.id), + let png = pngData(from: img) { + try? png.write(to: dir.appendingPathComponent(name), options: .atomic) + } + } + if let preview = workspace?.boardContainer.boardPNG() { + try? preview.write(to: dir.appendingPathComponent("preview.png"), options: .atomic) + } + // GC orphaned image files for this document's bundle only. + if let names = try? fm.contentsOfDirectory(atPath: dir.path) { + for n in names where n.hasPrefix("img-") && !kept.contains(n) { + try? fm.removeItem(at: dir.appendingPathComponent(n)) + } + } + } + + /// Import an image file as a new board card (re-encoded to PNG on save). + func importImageToBoard(url: URL, at center: NSPoint) { + guard let image = NSImage(contentsOf: url), let board = workspace?.boardContainer.boardView else { return } + board.addImageCard(image, at: center) + } + + func importImageToBoard(_ image: NSImage, at center: NSPoint) { + workspace?.boardContainer.boardView.addImageCard(image, at: center) + } + + private func pngData(from image: NSImage) -> Data? { + guard let tiff = image.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { return nil } + return rep.representation(using: .png, properties: [:]) + } + + /// Write the PNG to a temp file and reuse the scp sync engine to push it to + /// the configured tailnet Mac — same destination as text files. + func sendSketchToTailnet(pngData: Data) { + guard SyncSettings.isConfigured else { + workspace?.setStatus("Set a tailnet remote in Settings first") + return + } + let base = (displayName ?? "sketch").replacingOccurrences(of: " ", with: "_") + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("\(base)-sketch.png") + do { + try pngData.write(to: tmp) + } catch { + workspace?.setStatus("Couldn't stage PNG: \(error.localizedDescription)") + return + } + workspace?.setStatus("Sending sketch to \(SyncSettings.remote)…") + SyncEngine.upload(fileURL: tmp) { [weak self] result in + switch result { + case .success: + self?.workspace?.setStatus("Sketch sent to \(SyncSettings.remote) ✓") + case .failure(let error): + let line = error.message.split(separator: "\n").first.map(String.init) ?? "failed" + self?.workspace?.setStatus("Sketch sync failed — \(line)") + } + try? FileManager.default.removeItem(at: tmp) + } + } +} diff --git a/Sources/DrawingCanvas.swift b/Sources/DrawingCanvas.swift new file mode 100644 index 0000000..57a0d11 --- /dev/null +++ b/Sources/DrawingCanvas.swift @@ -0,0 +1,395 @@ +import AppKit + +enum DrawTool: String, CaseIterable { + case pen, marker, line, arrow, rect, oval, text, eraser + + var symbol: String { + switch self { + case .pen: return "pencil.tip" + case .marker: return "highlighter" + case .line: return "line.diagonal" + case .arrow: return "arrow.up.right" + case .rect: return "rectangle" + case .oval: return "oval" + case .text: return "textformat" + case .eraser: return "eraser" + } + } + + var label: String { rawValue.capitalized } +} + +/// One drawn element. Freehand tools keep a point list; shapes keep two corner +/// points; text keeps an origin + string. Stored as plain values so the whole +/// canvas serializes to JSON for an editable sidecar. +struct DrawElement: Codable { + var tool: String + var colorHex: String + var alpha: CGFloat + var lineWidth: CGFloat + var filled: Bool + var points: [CGPoint] + var text: String + + var drawTool: DrawTool { DrawTool(rawValue: tool) ?? .pen } + var color: NSColor { NSColor(hex: colorHex)?.withAlphaComponent(alpha) ?? .black } +} + +/// The drawing surface: an unflattened vector model rendered each frame, with +/// per-element undo/redo and PNG/JSON export. Tool/color/width are driven by +/// the palette to its left. +final class DrawCanvasView: NSView { + var tool: DrawTool = .pen + var strokeColor: NSColor = .systemRed + var fillEnabled = false + var lineWidth: CGFloat = 3 + var canvasBackground: NSColor = .white { didSet { needsDisplay = true } } + /// When false, the view is transparent to mouse events (used as the board's + /// bottom shapes layer so card drags win unless a draw tool is active). + var hitTestEnabled = true + + override func hitTest(_ point: NSPoint) -> NSView? { + hitTestEnabled ? super.hitTest(point) : nil + } + + private(set) var elements: [DrawElement] = [] + private var current: DrawElement? + private let undoMgr = UndoManager() + + /// Called whenever the model changes, so the host can mark the sidecar dirty. + var onChange: (() -> Void)? + + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + override var undoManager: UndoManager? { undoMgr } + + // MARK: - Rendering + + override func draw(_ dirtyRect: NSRect) { + canvasBackground.setFill() + bounds.fill() + for element in elements { render(element) } + if let current { render(current) } + } + + private func render(_ e: DrawElement) { + let color = e.color + color.setStroke() + color.setFill() + let path = NSBezierPath() + path.lineWidth = e.lineWidth + path.lineCapStyle = .round + path.lineJoinStyle = .round + + switch e.drawTool { + case .pen, .marker, .eraser: + guard let first = e.points.first else { return } + path.move(to: first) + for p in e.points.dropFirst() { path.line(to: p) } + if e.drawTool == .marker { color.withAlphaComponent(min(e.alpha, 0.4)).setStroke() } + path.stroke() + case .line: + guard e.points.count >= 2 else { return } + path.move(to: e.points[0]); path.line(to: e.points[1]); path.stroke() + case .arrow: + guard e.points.count >= 2 else { return } + drawArrow(from: e.points[0], to: e.points[1], width: e.lineWidth, color: color) + case .rect: + guard e.points.count >= 2 else { return } + let r = NSRect(corner: e.points[0], to: e.points[1]) + let rp = NSBezierPath(rect: r); rp.lineWidth = e.lineWidth + if e.filled { color.withAlphaComponent(0.25 * e.alpha).setFill(); rp.fill() } + color.setStroke(); rp.stroke() + case .oval: + guard e.points.count >= 2 else { return } + let r = NSRect(corner: e.points[0], to: e.points[1]) + let op = NSBezierPath(ovalIn: r); op.lineWidth = e.lineWidth + if e.filled { color.withAlphaComponent(0.25 * e.alpha).setFill(); op.fill() } + color.setStroke(); op.stroke() + case .text: + guard let origin = e.points.first else { return } + let font = NSFont.systemFont(ofSize: max(11, e.lineWidth * 5)) + (e.text as NSString).draw(at: origin, withAttributes: [.font: font, .foregroundColor: color]) + } + } + + private func drawArrow(from: NSPoint, to: NSPoint, width: CGFloat, color: NSColor) { + let shaft = NSBezierPath() + shaft.lineWidth = width + shaft.lineCapStyle = .round + shaft.move(to: from); shaft.line(to: to) + color.setStroke(); shaft.stroke() + + let angle = atan2(to.y - from.y, to.x - from.x) + let headLen = max(10, width * 4) + let spread = CGFloat.pi / 7 + let head = NSBezierPath() + head.move(to: to) + head.line(to: NSPoint(x: to.x - headLen * cos(angle - spread), y: to.y - headLen * sin(angle - spread))) + head.move(to: to) + head.line(to: NSPoint(x: to.x - headLen * cos(angle + spread), y: to.y - headLen * sin(angle + spread))) + head.lineWidth = width + head.lineCapStyle = .round + head.stroke() + } + + // MARK: - Mouse → drawing + + override func mouseDown(with event: NSEvent) { + let p = convert(event.locationInWindow, from: nil) + if tool == .text { + beginTextEntry(at: p) + return + } + if tool == .eraser { + eraseHits(at: p) + return + } + current = DrawElement( + tool: tool.rawValue, + colorHex: strokeColor.hexString, + alpha: strokeColor.alphaComponent, + lineWidth: lineWidth, + filled: fillEnabled, + points: [p], + text: "" + ) + needsDisplay = true + } + + override func mouseDragged(with event: NSEvent) { + guard current != nil else { return } + let p = convert(event.locationInWindow, from: nil) + switch tool { + case .pen, .marker: + current?.points.append(p) + case .eraser: + eraseHits(at: p) + return + default: + if current!.points.count < 2 { current?.points.append(p) } else { current?.points[1] = p } + } + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + guard let element = current else { return } + current = nil + // Ignore a stray click that produced no real shape. + if element.drawTool != .text, element.points.count < 2, + element.drawTool != .pen, element.drawTool != .marker { + needsDisplay = true + return + } + commit(element) + } + + // MARK: - Model mutation (undoable) + + private func commit(_ element: DrawElement) { + elements.append(element) + undoMgr.registerUndo(withTarget: self) { canvas in + canvas.removeLast(restoring: element) + } + undoMgr.setActionName("Draw \(element.drawTool.label)") + needsDisplay = true + onChange?() + } + + private func removeLast(restoring element: DrawElement) { + guard !elements.isEmpty else { return } + let removed = elements.removeLast() + undoMgr.registerUndo(withTarget: self) { canvas in + canvas.commit(removed) + } + needsDisplay = true + onChange?() + } + + private func eraseHits(at p: NSPoint) { + let hitRadius = max(8, lineWidth * 3) + let survivors = elements.filter { !$0.hits(point: p, within: hitRadius) } + guard survivors.count != elements.count else { return } + let before = elements + elements = survivors + undoMgr.registerUndo(withTarget: self) { canvas in + canvas.restore(before) + } + undoMgr.setActionName("Erase") + needsDisplay = true + onChange?() + } + + private func restore(_ snapshot: [DrawElement]) { + let before = elements + elements = snapshot + undoMgr.registerUndo(withTarget: self) { canvas in canvas.restore(before) } + needsDisplay = true + onChange?() + } + + private func beginTextEntry(at p: NSPoint) { + let field = NSTextField(frame: NSRect(x: p.x, y: p.y, width: 200, height: 24)) + field.font = NSFont.systemFont(ofSize: max(11, lineWidth * 5)) + field.textColor = strokeColor + field.backgroundColor = .clear + field.isBordered = false + field.focusRingType = .none + field.placeholderString = "type, ⏎" + field.target = self + field.action = #selector(commitTextField(_:)) + field.tag = 0 + addSubview(field) + window?.makeFirstResponder(field) + textEntryOrigin = p + activeTextField = field + } + + private var activeTextField: NSTextField? + private var textEntryOrigin: NSPoint = .zero + + @objc private func commitTextField(_ sender: NSTextField) { + let value = sender.stringValue + sender.removeFromSuperview() + activeTextField = nil + guard !value.isEmpty else { return } + var element = DrawElement(tool: DrawTool.text.rawValue, + colorHex: strokeColor.hexString, alpha: strokeColor.alphaComponent, + lineWidth: lineWidth, filled: false, + points: [textEntryOrigin], text: value) + // Nudge baseline so text sits where the click landed. + element.points = [NSPoint(x: textEntryOrigin.x, y: textEntryOrigin.y)] + commit(element) + } + + // MARK: - Commands + + func clearAll() { + guard !elements.isEmpty else { return } + let before = elements + elements = [] + undoMgr.registerUndo(withTarget: self) { canvas in canvas.restore(before) } + undoMgr.setActionName("Clear Canvas") + needsDisplay = true + onChange?() + } + + func undo() { if undoMgr.canUndo { undoMgr.undo() } } + func redo() { if undoMgr.canRedo { undoMgr.redo() } } + + // MARK: - Export / persistence + + func pngData() -> Data? { + guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil } + cacheDisplay(in: bounds, to: rep) + return rep.representation(using: .png, properties: [:]) + } + + struct CanvasDoc: Codable { + var backgroundHex: String + var elements: [DrawElement] + var width: CGFloat + var height: CGFloat + } + + func serialized() -> Data? { + let doc = CanvasDoc(backgroundHex: canvasBackground.hexString, elements: elements, + width: bounds.width, height: bounds.height) + return try? JSONEncoder().encode(doc) + } + + func load(from data: Data) { + guard let doc = try? JSONDecoder().decode(CanvasDoc.self, from: data) else { return } + if let bg = NSColor(hex: doc.backgroundHex) { canvasBackground = bg } + elements = doc.elements + undoMgr.removeAllActions() + needsDisplay = true + } + + var isEmpty: Bool { elements.isEmpty } + + /// Bounding rect of all drawn elements (nil if empty), for fit/export. + func contentBounds() -> NSRect? { + var rect: NSRect? + for e in elements { + for p in e.points { + let r = NSRect(x: p.x, y: p.y, width: 0, height: 0) + rect = rect?.union(r) ?? r + } + } + return rect?.insetBy(dx: -12, dy: -12) + } +} + +// MARK: - Geometry helpers + +private extension DrawElement { + func hits(point p: NSPoint, within r: CGFloat) -> Bool { + switch drawTool { + case .pen, .marker, .eraser, .line, .arrow: + for i in 0..= 2 else { return false } + return NSRect(corner: points[0], to: points[1]).insetBy(dx: -r, dy: -r).contains(p) + case .text: + guard let o = points.first else { return false } + return NSRect(x: o.x - r, y: o.y - r, width: 200, height: 28).contains(p) + } + } + + func distance(_ p: NSPoint, toSegment a: NSPoint, _ b: NSPoint) -> CGFloat { + let dx = b.x - a.x, dy = b.y - a.y + let lenSq = dx * dx + dy * dy + if lenSq == 0 { return p.distance(to: a) } + var t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq + t = max(0, min(1, t)) + return p.distance(to: NSPoint(x: a.x + t * dx, y: a.y + t * dy)) + } +} + +extension NSPoint { + func distance(to o: NSPoint) -> CGFloat { hypot(x - o.x, y - o.y) } +} + +extension NSRect { + init(corner a: NSPoint, to b: NSPoint) { + self.init(x: min(a.x, b.x), y: min(a.y, b.y), width: abs(a.x - b.x), height: abs(a.y - b.y)) + } +} + +// MARK: - Color hex + +extension NSColor { + /// "#RRGGBB" when opaque, "#RRGGBBAA" when translucent (so box/shape alpha + /// survives a save). + var hexString: String { + guard let c = usingColorSpace(.sRGB) else { return "#000000" } + let r = Int(round(c.redComponent * 255)) + let g = Int(round(c.greenComponent * 255)) + let b = Int(round(c.blueComponent * 255)) + let a = Int(round(c.alphaComponent * 255)) + return a >= 255 ? String(format: "#%02X%02X%02X", r, g, b) + : String(format: "#%02X%02X%02X%02X", r, g, b, a) + } + + convenience init?(hex: String) { + var s = hex.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("#") { s.removeFirst() } + if s.count == 6, let v = UInt32(s, radix: 16) { + self.init(srgbRed: CGFloat((v >> 16) & 0xFF) / 255, + green: CGFloat((v >> 8) & 0xFF) / 255, + blue: CGFloat(v & 0xFF) / 255, alpha: 1) + } else if s.count == 8, let v = UInt32(s, radix: 16) { + self.init(srgbRed: CGFloat((v >> 24) & 0xFF) / 255, + green: CGFloat((v >> 16) & 0xFF) / 255, + blue: CGFloat((v >> 8) & 0xFF) / 255, + alpha: CGFloat(v & 0xFF) / 255) + } else { + return nil + } + } +} diff --git a/Sources/HTMLScanner.swift b/Sources/HTMLScanner.swift new file mode 100644 index 0000000..6492869 --- /dev/null +++ b/Sources/HTMLScanner.swift @@ -0,0 +1,739 @@ +import Foundation + +/// Lightweight streaming HTML tokenizer + selector tools for inspector-copied +/// markup. Not a spec HTML parser — built for "paste from devtools, +/// find the repeating rows, pull their data" scraping workflows. +/// +/// Selector syntax: `tag`, `#id`, `.class`, compounds (`tr.shortcut_navigable`), +/// attribute filters (`[data-id]`, `[!class]`, `[rel=tag]`, `[href^=/seller/]`), +/// space-separated descendant chains, and a trailing `@attr` to extract an +/// attribute value from the matched element. +enum HTMLScanner { + + // MARK: - Public: selector summary + + struct SummaryData { + let entries: [(selector: String, count: Int, sample: String)] + let elementCount: Int + } + + static func summarize(html: String) -> SummaryData { + var counts: [String: Int] = [:] + var samples: [String: String] = [:] + + struct Entry { + let tag: String + let key: String? + var sampling: Bool + var buf: String + } + var stack: [Entry] = [] + var totalElements = 0 + + func finalize(_ entry: Entry) { + guard let key = entry.key, entry.sampling else { return } + if samples[key, default: ""].isEmpty { + samples[key] = String(entry.buf.prefix(90)) + } + } + + tokenize(html, openTag: { tag, attrs, closed in + let id = normalizedID(attrs) + let classes = splitClasses(attrs["class"]) + var key: String? + if id != nil || !classes.isEmpty { + key = selectorString(tag: tag, id: id, classes: classes) + counts[key!, default: 0] += 1 + totalElements += 1 + } + if closed { return } + // Keep sampling until a NON-empty sample lands, so a first + // occurrence that wraps only an doesn't lose the sample. + let sampling = key.map { samples[$0, default: ""].isEmpty } ?? false + stack.append(Entry(tag: tag, key: key, sampling: sampling, buf: "")) + }, closeTag: { tag in + guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return } + for entry in stack[matchIdx...].reversed() { finalize(entry) } + stack.removeSubrange(matchIdx...) + }, text: { raw in + let collapsed = collapseWhitespace(decodeEntities(raw)) + guard !collapsed.isEmpty else { return } + for i in stack.indices where stack[i].sampling && stack[i].buf.count < 90 { + stack[i].buf += (stack[i].buf.isEmpty ? "" : " ") + collapsed + } + }) + for entry in stack.reversed() { finalize(entry) } + + let sorted = counts.sorted { $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key } + return SummaryData( + entries: sorted.map { (selector: $0.key, count: $0.value, sample: samples[$0.key] ?? "") }, + elementCount: totalElements + ) + } + + static func formatSummary(_ data: SummaryData, sourceName: String) -> String { + var out = "Selector summary of \(sourceName)\n" + out += "\(data.elementCount) elements with an id or class, \(data.entries.count) unique selectors.\n" + out += "Repeating rows (your data) sort to the top. Next: Tools → Extract Text for Selector…\n\n" + for entry in data.entries { + out += String(format: "%5d× %@\n", entry.count, entry.selector) + if !entry.sample.isEmpty { + out += " ↳ \(entry.sample)\n" + } + } + return out + } + + // MARK: - Public: extract text for a selector + + struct ExtractResult { + let matchCount: Int + let body: String + } + + /// Returns nil if the selector string can't be parsed. A trailing `@attr` + /// prepends that attribute's value as a tab-separated first column. + static func extractText(html: String, selector: String, sourceName: String) -> ExtractResult? { + guard let compounds = parseSelector(selector) else { return nil } + let extractAttr = compounds.last?.extractAttr + + struct Entry { + let tag: String + let id: String? + let classes: Set + let attrs: [String: String] + var isCaptureRoot: Bool + } + var stack: [Entry] = [] + var capturing = false + var buf = "" + var rootAttrValue = "" + var records: [String] = [] + + func selectorMatches() -> Bool { + matchChain(compounds, stack: stack.map { ($0.tag, $0.id, $0.classes, $0.attrs) }, scopeStart: 0) + } + + func attrValue(_ attrs: [String: String]) -> String { + extractAttr.flatMap { attrs[$0] } ?? "" + } + + func finishRecord() { + var record = buf.trimmingCharacters(in: CharacterSet(charactersIn: " \t")) + if extractAttr != nil { + record = rootAttrValue + "\t" + record + } + records.append(record) + buf = "" + rootAttrValue = "" + capturing = false + } + + tokenize(html, openTag: { tag, attrs, closed in + let entry = Entry(tag: tag, id: normalizedID(attrs), + classes: Set(splitClasses(attrs["class"])), + attrs: attrs, isCaptureRoot: false) + if closed { + if !capturing { + stack.append(entry) + if selectorMatches() { + records.append(extractAttr != nil ? attrValue(attrs) + "\t" : "") + } + stack.removeLast() + } + return + } + stack.append(entry) + if selectorMatches() { + if capturing { + // A new match opening mid-capture means the previous row's + // close tag was missing (raw source) or rows nest — either + // way, finish the old record so rows don't silently merge. + finishRecord() + if let oldRoot = stack.firstIndex(where: { $0.isCaptureRoot }) { + stack[oldRoot].isCaptureRoot = false + } + } + stack[stack.count - 1].isCaptureRoot = true + capturing = true + buf = "" + rootAttrValue = attrValue(attrs) + } + }, closeTag: { tag in + guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return } + let popped = stack[matchIdx...] + stack.removeSubrange(matchIdx...) + if popped.contains(where: { $0.isCaptureRoot }) { + finishRecord() + } else if capturing, tag == "td" || tag == "th", + !buf.isEmpty, !buf.hasSuffix("\t") { + buf += "\t" // cell boundary → tab, so table rows come out TSV-ish + } + }, text: { raw in + guard capturing else { return } + let collapsed = collapseWhitespace(decodeEntities(raw)) + guard !collapsed.isEmpty else { return } + if !buf.isEmpty && !buf.hasSuffix("\t") { buf += " " } + buf += collapsed + }) + if capturing { finishRecord() } // unclosed capture at EOF + + var out = "# \(records.count) match\(records.count == 1 ? "" : "es") for `\(selector)` in \(sourceName)\n" + out += records.joined(separator: "\n") + if !records.isEmpty { out += "\n" } + return ExtractResult(matchCount: records.count, body: out) + } + + // MARK: - Public: structured row extraction (scrape profiles) + + struct RowExtraction { + let header: [String] + let rows: [[String]] + } + + /// For each element matching rowSelector, capture named fields by selector + /// scoped to that row's subtree (the row element itself included, so + /// "tr@data-release-id" reads an attribute off the row). First match per + /// field wins. Returns nil if any selector fails to parse. + static func extractRows(html: String, rowSelector: String, + fields: [(name: String, selector: String)]) -> RowExtraction? { + guard let rowCompounds = parseSelector(rowSelector) else { return nil } + struct FieldSpec { + let name: String + let compounds: [Compound] + let attr: String? + } + var specs: [FieldSpec] = [] + for field in fields { + guard let comps = parseSelector(field.selector) else { return nil } + specs.append(FieldSpec(name: field.name, compounds: comps, attr: comps.last?.extractAttr)) + } + + typealias StackEl = (tag: String, id: String?, classes: Set, attrs: [String: String]) + enum FieldState { + case pending + case capturing(rootIndex: Int, buf: String) + case done(String) + } + var stack: [StackEl] = [] + var inRow = false + var rowRootIndex = -1 + var fieldStates: [FieldState] = [] + var rows: [[String]] = [] + + func startRow() { + inRow = true + rowRootIndex = stack.count - 1 + fieldStates = Array(repeating: .pending, count: specs.count) + tryMatchFields(closedAttrs: nil) + } + + func finishRow() { + let columns = fieldStates.map { state -> String in + switch state { + case .pending: return "" + case .capturing(_, let buf): return buf.trimmingCharacters(in: .whitespaces) + case .done(let value): return value.trimmingCharacters(in: .whitespaces) + } + } + rows.append(columns) + inRow = false + rowRootIndex = -1 + fieldStates = [] + } + + // closedAttrs non-nil = the just-seen element is void/self-closed and + // sits on top of the stack only transiently. + func tryMatchFields(closedAttrs: [String: String]?) { + guard inRow else { return } + for i in specs.indices { + guard case .pending = fieldStates[i] else { continue } + guard matchChain(specs[i].compounds, stack: stack, scopeStart: rowRootIndex) else { continue } + if let attr = specs[i].attr { + fieldStates[i] = .done(stack[stack.count - 1].attrs[attr] ?? "") + } else if closedAttrs != nil { + fieldStates[i] = .done("") // void element has no text + } else { + fieldStates[i] = .capturing(rootIndex: stack.count - 1, buf: "") + } + } + } + + tokenize(html, openTag: { tag, attrs, closed in + let el: StackEl = (tag, normalizedID(attrs), Set(splitClasses(attrs["class"])), attrs) + if closed { + stack.append(el) + if inRow { tryMatchFields(closedAttrs: attrs) } + stack.removeLast() + return + } + stack.append(el) + if matchChain(rowCompounds, stack: stack, scopeStart: 0) { + if inRow { finishRow() } // unclosed previous row + startRow() + } else if inRow { + tryMatchFields(closedAttrs: nil) + } + }, closeTag: { tag in + guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return } + for i in fieldStates.indices { + if case .capturing(let root, let buf) = fieldStates[i], root >= matchIdx { + fieldStates[i] = .done(buf) + } + } + if inRow && rowRootIndex >= matchIdx { finishRow() } + stack.removeSubrange(matchIdx...) + }, text: { raw in + guard inRow else { return } + let collapsed = collapseWhitespace(decodeEntities(raw)) + guard !collapsed.isEmpty else { return } + for i in fieldStates.indices { + if case .capturing(let root, var buf) = fieldStates[i] { + if !buf.isEmpty { buf += " " } + buf += collapsed + fieldStates[i] = .capturing(rootIndex: root, buf: buf) + } + } + }) + if inRow { finishRow() } + + return RowExtraction(header: specs.map { $0.name }, rows: rows) + } + + /// True if the selector parses (for validating user/profile input). + static func isValidSelector(_ selector: String) -> Bool { + parseSelector(selector) != nil + } + + // MARK: - Selector parsing & matching + + private static func parseSelector(_ selector: String) -> [Compound]? { + let parts = selector.split(separator: " ").map(String.init) + guard !parts.isEmpty else { return nil } + var compounds: [Compound] = [] + for part in parts { + guard let c = Compound(token: part) else { return nil } + compounds.append(c) + } + // @attr extraction is only meaningful on the final compound + for c in compounds.dropLast() where c.extractAttr != nil { return nil } + return compounds + } + + /// Match the last compound against the top of the stack and earlier + /// compounds against ancestors in order, never looking below scopeStart. + private static func matchChain(_ compounds: [Compound], + stack: [(tag: String, id: String?, classes: Set, attrs: [String: String])], + scopeStart: Int) -> Bool { + guard let last = stack.last, + compounds[compounds.count - 1].matches(tag: last.tag, id: last.id, classes: last.classes, attrs: last.attrs) + else { return false } + var ci = compounds.count - 2 + var si = stack.count - 2 + while ci >= 0 { + while si >= scopeStart, + !compounds[ci].matches(tag: stack[si].tag, id: stack[si].id, classes: stack[si].classes, attrs: stack[si].attrs) { + si -= 1 + } + if si < scopeStart { return false } + ci -= 1 + si -= 1 + } + return true + } + + private struct AttrFilter { + enum Op { case present, absent, equals, prefix } + let name: String + let op: Op + let value: String + + init?(_ content: String) { + let s = content.trimmingCharacters(in: .whitespaces) + guard !s.isEmpty else { return nil } + if s.hasPrefix("!") { + let n = String(s.dropFirst()).trimmingCharacters(in: .whitespaces).lowercased() + guard !n.isEmpty else { return nil } + name = n; op = .absent; value = "" + } else if let r = s.range(of: "^=") { + let n = String(s[.. String { + let t = v.trimmingCharacters(in: .whitespaces) + if t.count >= 2, + (t.hasPrefix("\"") && t.hasSuffix("\"")) || (t.hasPrefix("'") && t.hasSuffix("'")) { + return String(t.dropFirst().dropLast()) + } + return t + } + + func matches(_ attrs: [String: String]) -> Bool { + switch op { + case .present: return attrs[name] != nil + case .absent: return attrs[name] == nil + case .equals: return attrs[name] == value + case .prefix: return attrs[name]?.hasPrefix(value) ?? false + } + } + } + + private struct Compound { + var tag: String? + var id: String? + var classes: [String] = [] + var filters: [AttrFilter] = [] + var extractAttr: String? + + init?(token: String) { + var mode: Character = "t" + var current = "" + + func commit() -> Bool { + guard !current.isEmpty else { return false } + switch mode { + case "t": + guard tag == nil else { return false } // "div[x]y" — two tags + let t = current.lowercased() + // Reject combinators/pseudo junk (">", "+", "td:nth-child(2)") + // so the "Bad Selector" path fires instead of 0 matches. + // ':' stays legal (namespaced tags); ids/classes aren't + // validated — real class values contain odd characters. + guard t == "*" || t.allSatisfy({ + $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == ":" + }) else { return false } + tag = t + case "#": id = current + default: classes.append(current) + } + current = "" + return true + } + + let chars = Array(token) + var i = 0 + while i < chars.count { + let ch = chars[i] + if ch == "[" { + if !current.isEmpty { + guard commit() else { return nil } + } else if mode != "t" { + return nil // ".[x]" + } + mode = "t" + guard let close = chars[i...].firstIndex(of: "]") else { return nil } + guard let filter = AttrFilter(String(chars[(i + 1).., attrs: [String: String]) -> Bool { + if let tag, tag != t { return false } + if let id, id != i { return false } + for c in classes where !cs.contains(c) { return false } + for f in filters where !f.matches(attrs) { return false } + return true + } + } + + // MARK: - Tokenizer + + private static let voidTags: Set = [ + "area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "param", "source", "track", "wbr", + ] + private static let rawTextTags: Set = ["script", "style", "textarea", "title"] + // Tags that implicitly close a same-tag sibling (covers unclosed + //
  • //

    runs in raw page source; inspector copies are balanced). + private static let siblingClosingTags: Set = ["li", "tr", "td", "th", "p", "dt", "dd", "option"] + // Inline tags the sibling-close rule may look "through" — an unclosed + // between two

  • s must not defeat the implicit close. Bounded so + // legitimately nested lists (li inside ul inside li) stay nested. + private static let inlineTags: Set = [ + "a", "abbr", "b", "code", "em", "font", "i", "label", "mark", + "s", "small", "span", "strong", "sub", "sup", "time", "u", + ] + + /// Walks the markup once. openTag receives the tag name, all attributes + /// (lowercased names, entity-decoded values, first occurrence wins), and + /// whether the element can't have children (self-closed or void). + private static func tokenize(_ html: String, + openTag: (String, [String: String], Bool) -> Void, + closeTag: (String) -> Void, + text: (String) -> Void) { + let bytes = Array(html.utf8) + let n = bytes.count + let lt = UInt8(ascii: "<"), gt = UInt8(ascii: ">") + let slash = UInt8(ascii: "/"), bang = UInt8(ascii: "!") + let eq = UInt8(ascii: "="), dash = UInt8(ascii: "-") + let dq = UInt8(ascii: "\""), sq = UInt8(ascii: "'") + + func isSpace(_ b: UInt8) -> Bool { b == 0x20 || b == 0x09 || b == 0x0A || b == 0x0D || b == 0x0C } + func isAlpha(_ b: UInt8) -> Bool { (b | 0x20) >= 0x61 && (b | 0x20) <= 0x7A } + func isNameByte(_ b: UInt8) -> Bool { + isAlpha(b) || (b >= 0x30 && b <= 0x39) || b == dash || b == UInt8(ascii: "_") || b == UInt8(ascii: ":") + } + + var i = 0 + var textStart = 0 + var openStack: [String] = [] + + func flushText(upTo end: Int) { + if end > textStart { + text(String(decoding: bytes[textStart.. and close + // (their terminator overlaps the opening dashes). + var j = i + 2 + while j + 2 < n && !(bytes[j] == dash && bytes[j + 1] == dash && bytes[j + 2] == gt) { j += 1 } + i = min(n, j + 3) + } else { + var j = i + 2 + while j < n && bytes[j] != gt { j += 1 } + i = min(n, j + 1) + } + textStart = i + } else if next == slash { + flushText(upTo: i) + var j = i + 2 + var name = "" + while j < n, isNameByte(bytes[j]) { name.append(Character(UnicodeScalar(bytes[j]))); j += 1 } + while j < n && bytes[j] != gt { j += 1 } + i = min(n, j + 1) + textStart = i + if !name.isEmpty { emitClose(name.lowercased()) } + } else if isAlpha(next) { + flushText(upTo: i) + var j = i + 1 + var name = "" + while j < n, isNameByte(bytes[j]) { name.append(Character(UnicodeScalar(bytes[j]))); j += 1 } + let tag = name.lowercased() + + var attrs: [String: String] = [:] + var selfClosed = false + attrs: while j < n { + while j < n, isSpace(bytes[j]) { j += 1 } + if j >= n { break } + if bytes[j] == gt { j += 1; break } + if bytes[j] == slash { + if j + 1 < n, bytes[j + 1] == gt { selfClosed = true; j += 2; break } + j += 1 + continue + } + var attrName = "" + while j < n, !isSpace(bytes[j]), bytes[j] != eq, bytes[j] != gt, bytes[j] != slash { + attrName.append(Character(UnicodeScalar(bytes[j]))) + j += 1 + } + var value = "" + while j < n, isSpace(bytes[j]) { j += 1 } + if j < n, bytes[j] == eq { + j += 1 + while j < n, isSpace(bytes[j]) { j += 1 } + if j < n, bytes[j] == dq || bytes[j] == sq { + let quote = bytes[j] + j += 1 + let start = j + while j < n, bytes[j] != quote { j += 1 } + value = String(decoding: bytes[start..= n { break attrs } + } + i = j + textStart = i + + if siblingClosingTags.contains(tag) { + var idx = openStack.count - 1 + while idx >= 0, inlineTags.contains(openStack[idx]) { idx -= 1 } + if idx >= 0, openStack[idx] == tag { emitClose(tag) } + } + let childless = selfClosed || voidTags.contains(tag) + openTag(tag, attrs, childless) + if !childless && !rawTextTags.contains(tag) { + openStack.append(tag) + } + + if !childless && rawTextTags.contains(tag) { + // Skip raw content (scripts, styles) up to the real closing tag. + let needle = Array(": the spec requires + // '>', '/', or whitespace after the tag name. + let after = k + needle.count + if after < n { + let b = bytes[after] + if !(b == gt || b == slash || isSpace(b)) { k += 1; continue search } + } + break + } + var j2 = min(k, n) + while j2 < n && bytes[j2] != gt { j2 += 1 } + i = min(n, j2 + 1) + textStart = i + closeTag(tag) + } + } else { + i += 1 // stray '<' in text + } + } + flushText(upTo: n) + } + + // MARK: - Text helpers + + private static func normalizedID(_ attrs: [String: String]) -> String? { + guard let id = attrs["id"]?.trimmingCharacters(in: .whitespaces), !id.isEmpty else { return nil } + return id + } + + private static func splitClasses(_ value: String?) -> [String] { + guard let value else { return [] } + return value.split(whereSeparator: { $0.isWhitespace }).map(String.init) + } + + private static func selectorString(tag: String, id: String?, classes: [String]) -> String { + var s = tag + if let id, !id.isEmpty { s += "#\(id)" } + for c in classes { s += ".\(c)" } + return s + } + + private static func collapseWhitespace(_ s: String) -> String { + s.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + + private static func decodeEntities(_ s: String) -> String { + guard s.contains("&") else { return s } + var result = "" + result.reserveCapacity(s.count) + var i = s.startIndex + while i < s.endIndex { + if s[i] == "&" { + var j = s.index(after: i) + var entity = "" + var terminated = false + var steps = 0 + while j < s.endIndex, steps < 12 { + if s[j] == ";" { terminated = true; break } + entity.append(s[j]) + j = s.index(after: j) + steps += 1 + } + if terminated, let decoded = decodeEntity(entity) { + result.append(decoded) + i = s.index(after: j) + continue + } + } + result.append(s[i]) + i = s.index(after: i) + } + return result + } + + private static func decodeEntity(_ e: String) -> Character? { + switch e { + case "amp": return "&" + case "lt": return "<" + case "gt": return ">" + case "quot": return "\"" + case "apos": return "'" + case "nbsp": return " " + case "ndash": return "–" + case "mdash": return "—" + case "hellip": return "…" + default: + if e.hasPrefix("#x") || e.hasPrefix("#X"), + let v = UInt32(e.dropFirst(2), radix: 16), let u = UnicodeScalar(v) { + return Character(u) + } + if e.hasPrefix("#"), let v = UInt32(e.dropFirst()), let u = UnicodeScalar(v) { + return Character(u) + } + return nil + } + } +} diff --git a/Sources/LineNumberRuler.swift b/Sources/LineNumberRuler.swift new file mode 100644 index 0000000..af67a00 --- /dev/null +++ b/Sources/LineNumberRuler.swift @@ -0,0 +1,148 @@ +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) + } +} diff --git a/Sources/Markdown.swift b/Sources/Markdown.swift new file mode 100644 index 0000000..fd624ac --- /dev/null +++ b/Sources/Markdown.swift @@ -0,0 +1,292 @@ +import AppKit + +/// A pragmatic Markdown → NSAttributedString renderer for the live preview. +/// Handles the constructs people actually type: ATX headings, bold/italic/ +/// bold-italic, inline code, fenced & indented code, blockquotes, ordered and +/// unordered lists, horizontal rules, and links. Colors come from the theme. +enum MarkdownRenderer { + + static func render(_ markdown: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let out = NSMutableAttributedString() + let lines = markdown.components(separatedBy: "\n") + var i = 0 + var inFence = false + var fenceBuffer: [String] = [] + + func flushFence() { + let code = fenceBuffer.joined(separator: "\n") + out.append(codeBlock(code, theme: theme, baseSize: baseSize)) + fenceBuffer = [] + } + + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") { + if inFence { flushFence(); inFence = false } else { inFence = true } + i += 1 + continue + } + if inFence { fenceBuffer.append(line); i += 1; continue } + + // Horizontal rule + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + out.append(horizontalRule(theme: theme, baseSize: baseSize)) + i += 1 + continue + } + + // ATX heading + if let (level, content) = heading(trimmed) { + out.append(headingLine(content, level: level, theme: theme, baseSize: baseSize)) + i += 1 + continue + } + + // Blockquote + if trimmed.hasPrefix(">") { + let content = String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces) + out.append(blockquote(content, theme: theme, baseSize: baseSize)) + i += 1 + continue + } + + // Unordered list item + if let marker = unorderedMarker(trimmed) { + let content = String(trimmed.dropFirst(marker.count)).trimmingCharacters(in: .whitespaces) + out.append(listItem(bullet: "• ", content: content, theme: theme, baseSize: baseSize)) + i += 1 + continue + } + + // Ordered list item + if let (number, content) = orderedItem(trimmed) { + out.append(listItem(bullet: "\(number). ", content: content, theme: theme, baseSize: baseSize)) + i += 1 + continue + } + + // Blank line → paragraph spacing + if trimmed.isEmpty { + out.append(NSAttributedString(string: "\n", attributes: [.font: bodyFont(baseSize)])) + i += 1 + continue + } + + // Paragraph + let para = inline(trimmed, theme: theme, baseSize: baseSize, font: bodyFont(baseSize)) + out.append(para) + out.append(NSAttributedString(string: "\n", attributes: [.font: bodyFont(baseSize)])) + i += 1 + } + if inFence { flushFence() } + // Every builder already sets its own foreground color, so there's no + // blanket recolor here — that would clobber links, code, and accents. + return out + } + + // MARK: - Block builders + + private static func bodyFont(_ size: CGFloat) -> NSFont { NSFont.systemFont(ofSize: size) } + private static func monoFont(_ size: CGFloat) -> NSFont { NSFont.monospacedSystemFont(ofSize: size, weight: .regular) } + + private static func heading(_ s: String) -> (Int, String)? { + var level = 0 + for ch in s { + if ch == "#" { level += 1 } else { break } + } + guard level >= 1, level <= 6 else { return nil } + let rest = s.dropFirst(level) + guard rest.first == " " || rest.isEmpty else { return nil } + return (level, rest.trimmingCharacters(in: .whitespaces)) + } + + private static func unorderedMarker(_ s: String) -> String? { + for m in ["- ", "* ", "+ "] where s.hasPrefix(m) { return String(m.dropLast()) } + return nil + } + + private static func orderedItem(_ s: String) -> (Int, String)? { + var digits = "" + for ch in s { + if ch.isNumber { digits.append(ch) } else { break } + } + guard let n = Int(digits) else { return nil } + let rest = s.dropFirst(digits.count) + guard rest.first == "." || rest.first == ")" else { return nil } + let content = rest.dropFirst().trimmingCharacters(in: .whitespaces) + return (n, content) + } + + private static func headingLine(_ content: String, level: Int, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let scale: CGFloat = [1.9, 1.55, 1.3, 1.15, 1.05, 1.0][min(level - 1, 5)] + let weight: NSFont.Weight = level <= 2 ? .bold : .semibold + let font = NSFont.systemFont(ofSize: round(baseSize * scale), weight: weight) + let para = NSMutableParagraphStyle() + para.paragraphSpacingBefore = baseSize * 0.6 + para.paragraphSpacing = baseSize * 0.3 + let attr = inline(content, theme: theme, baseSize: baseSize, font: font) + let m = NSMutableAttributedString(attributedString: attr) + m.addAttributes([.paragraphStyle: para], range: NSRange(location: 0, length: m.length)) + m.append(NSAttributedString(string: "\n")) + return m + } + + private static func codeBlock(_ code: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let para = NSMutableParagraphStyle() + para.firstLineHeadIndent = 12 + para.headIndent = 12 + para.paragraphSpacingBefore = baseSize * 0.3 + para.paragraphSpacing = baseSize * 0.3 + let bg = theme.isDark ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.05) + let attrs: [NSAttributedString.Key: Any] = [ + .font: monoFont(baseSize - 1), + .foregroundColor: theme.foreground, + .backgroundColor: bg, + .paragraphStyle: para, + ] + return NSAttributedString(string: code + "\n", attributes: attrs) + } + + private static func blockquote(_ content: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let para = NSMutableParagraphStyle() + para.firstLineHeadIndent = 14 + para.headIndent = 14 + para.paragraphSpacingBefore = 2 + para.paragraphSpacing = 2 + let attr = inline(content, theme: theme, baseSize: baseSize, + font: NSFontManager.shared.convert(bodyFont(baseSize), toHaveTrait: .italicFontMask)) + let m = NSMutableAttributedString(attributedString: attr) + m.addAttributes([.paragraphStyle: para, .foregroundColor: theme.secondaryText], + range: NSRange(location: 0, length: m.length)) + m.append(NSAttributedString(string: "\n")) + return m + } + + private static func listItem(bullet: String, content: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let para = NSMutableParagraphStyle() + para.firstLineHeadIndent = 16 + para.headIndent = 16 + (bullet as NSString).size(withAttributes: [.font: bodyFont(baseSize)]).width + let m = NSMutableAttributedString() + m.append(NSAttributedString(string: bullet, attributes: [ + .font: bodyFont(baseSize), .foregroundColor: theme.accent, .paragraphStyle: para, + ])) + let body = inline(content, theme: theme, baseSize: baseSize, font: bodyFont(baseSize)) + let bodyM = NSMutableAttributedString(attributedString: body) + bodyM.addAttribute(.paragraphStyle, value: para, range: NSRange(location: 0, length: bodyM.length)) + m.append(bodyM) + m.append(NSAttributedString(string: "\n")) + return m + } + + private static func horizontalRule(theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString { + let para = NSMutableParagraphStyle() + para.paragraphSpacingBefore = baseSize * 0.4 + para.paragraphSpacing = baseSize * 0.4 + return NSAttributedString(string: "________________________\n", attributes: [ + .font: bodyFont(baseSize), + .foregroundColor: theme.secondaryText.withAlphaComponent(0.4), + .paragraphStyle: para, + ]) + } + + // MARK: - Inline parsing + + /// Parses bold/italic/code/links inside a single line. `font` is the base + /// font for this block (so headings keep their size while going bold etc). + private static func inline(_ text: String, theme: EditorTheme, baseSize: CGFloat, font: NSFont) -> NSAttributedString { + let result = NSMutableAttributedString() + let chars = Array(text) + var i = 0 + var plainStart = 0 + + func flushPlain(upTo end: Int) { + if end > plainStart { + let s = String(chars[plainStart.. NSFont { + var f = font + let mgr = NSFontManager.shared + if bold { f = mgr.convert(f, toHaveTrait: .boldFontMask) } + if italic { f = mgr.convert(f, toHaveTrait: .italicFontMask) } + return f + } + + func matchRun(_ marker: Character, count: Int, from start: Int) -> Int? { + // Find a closing run of `count` markers after start. + var j = start + while j < chars.count { + if chars[j] == marker { + var run = 0 + while j + run < chars.count && chars[j + run] == marker { run += 1 } + if run >= count { return j } + j += run + } else { + j += 1 + } + } + return nil + } + + while i < chars.count { + let c = chars[i] + // Inline code `...` + if c == "`" { + if let close = chars[(i + 1)...].firstIndex(of: "`") { + flushPlain(upTo: i) + let codeText = String(chars[(i + 1)..= 2 + let italic = count == 1 || count == 3 + let inner = inline(innerText, theme: theme, baseSize: baseSize, font: emphasisFont(bold: bold, italic: italic)) + result.append(inner) + i = close + count + plainStart = i + continue + } + } + // Link [text](url) + if c == "[" { + if let closeBracket = chars[(i + 1)...].firstIndex(of: "]"), + closeBracket + 1 < chars.count, chars[closeBracket + 1] == "(", + let closeParen = chars[(closeBracket + 2)...].firstIndex(of: ")") { + flushPlain(upTo: i) + let label = String(chars[(i + 1).. String { + SHA256.hash(data: Data(s.utf8)).map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Public entry points (call from main; completions on main) + + static func test(uri: String, completion: @escaping (Result) -> Void) { + let sql = schemaSQL + "\nSELECT current_database() || ' · ' || split_part(version(), ' on ', 1);" + runSQL(uri: uri, sql: sql) { result in + switch result { + case .success(let out): + let last = out.split(separator: "\n").last.map(String.init) ?? "ok" + completion(.success("Connected, tables ready — \(last)")) + case .failure(let error): + completion(.failure(error)) + } + } + } + + static func ingest(_ page: PageIngest, force: Bool, + completion: @escaping (Result) -> Void) { + let uri = PGSettings.uri + + func runMainIngest() { + let built = buildIngestSQL(page) + runSQL(uri: uri, sql: built.sql) { result in + switch result { + case .success(let out): + guard let idLine = out.split(separator: "\n").last, + let pageId = Int64(idLine.trimmingCharacters(in: .whitespaces)) else { + completion(.failure(PGError(message: "Couldn't read the new page id from psql output: \(out.suffix(120))"))) + return + } + completion(.success(IngestReport(pageId: pageId, + selectorCount: page.selectors.count, + rowCount: page.rows.count, + snapshotCount: built.snapshotCount, + skippedRows: built.skipped, + skippedDuplicate: false))) + case .failure(let error): + completion(.failure(error)) + } + } + } + + if force || page.url == nil { + runMainIngest() + return + } + // Auto mode: skip if the latest capture of this URL has identical content. + let checkSQL = schemaSQL + """ + + SELECT content_hash FROM tp_pages WHERE url = \(lit(page.url)) + ORDER BY captured_at DESC LIMIT 1; + """ + runSQL(uri: uri, sql: checkSQL) { result in + switch result { + case .success(let out): + let lastHash = out.split(separator: "\n").last.map(String.init) ?? "" + if lastHash == page.contentHash { + completion(.success(IngestReport(pageId: -1, selectorCount: 0, rowCount: 0, + snapshotCount: 0, skippedRows: 0, skippedDuplicate: true))) + } else { + runMainIngest() + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: - SQL construction + + private static let schemaSQL = """ + CREATE TABLE IF NOT EXISTS tp_pages ( + id BIGSERIAL PRIMARY KEY, + url TEXT, + source_name TEXT, + content_hash TEXT, + element_count INT, + selector_count INT, + captured_at TIMESTAMPTZ DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS tp_selectors ( + id BIGSERIAL PRIMARY KEY, + page_id BIGINT REFERENCES tp_pages(id) ON DELETE CASCADE, + selector TEXT NOT NULL, + match_count INT NOT NULL, + sample TEXT + ); + CREATE TABLE IF NOT EXISTS tp_rows ( + id BIGSERIAL PRIMARY KEY, + page_id BIGINT REFERENCES tp_pages(id) ON DELETE CASCADE, + profile TEXT, + row_num INT, + fields JSONB + ); + CREATE INDEX IF NOT EXISTS idx_tp_pages_url ON tp_pages(url); + CREATE INDEX IF NOT EXISTS idx_tp_rows_page ON tp_rows(page_id); + CREATE TABLE IF NOT EXISTS discogs_sellers ( + seller_id BIGINT PRIMARY KEY, + username VARCHAR(100) NOT NULL, + last_scraped_at TIMESTAMPTZ DEFAULT now() + ); + -- A username can be recycled across seller_ids over time; the seller_id is + -- the real key. Drop the old UNIQUE(username) so a reused name can't abort + -- a whole page ingest. (IF NOT EXISTS skips the table on existing installs, + -- so the constraint must be dropped explicitly.) + ALTER TABLE discogs_sellers DROP CONSTRAINT IF EXISTS discogs_sellers_username_key; + CREATE TABLE IF NOT EXISTS marketplace_snapshots ( + item_id BIGINT PRIMARY KEY, + release_id BIGINT NOT NULL, + seller_id BIGINT REFERENCES discogs_sellers(seller_id), + title TEXT, + listing_url TEXT, + media_condition VARCHAR(120), + sleeve_condition VARCHAR(120), + price_currency VARCHAR(10), + price_value NUMERIC(12,2), + shipping_value NUMERIC(12,2), + first_seen TIMESTAMPTZ DEFAULT now(), + last_seen TIMESTAMPTZ DEFAULT now(), + is_active BOOLEAN DEFAULT TRUE + ); + CREATE INDEX IF NOT EXISTS idx_market_release_id ON marketplace_snapshots(release_id); + CREATE INDEX IF NOT EXISTS idx_market_seller_active ON marketplace_snapshots(seller_id) WHERE is_active = TRUE; + """ + + private static func buildIngestSQL(_ page: PageIngest) -> (sql: String, snapshotCount: Int, skipped: Int) { + var sql = schemaSQL + "\nBEGIN;\n" + sql += """ + INSERT INTO tp_pages (url, source_name, content_hash, element_count, selector_count) + VALUES (\(lit(page.url)), \(lit(page.sourceName)), \(lit(page.contentHash)), \(page.elementCount), \(page.selectors.count)) + RETURNING id AS page_id \\gset + + """ + + for chunk in page.selectors.chunked(into: 500) { + let values = chunk.map { + "(:page_id, \(lit($0.selector)), \($0.count), \(lit(String($0.sample.prefix(300)))))" + }.joined(separator: ",\n") + sql += "INSERT INTO tp_selectors (page_id, selector, match_count, sample) VALUES\n\(values);\n" + } + + if !page.rows.isEmpty, let profile = page.profileName { + for (chunkIndex, chunk) in page.rows.chunked(into: 200).enumerated() { + let values = chunk.enumerated().map { offset, row -> String in + let rowNum = chunkIndex * 200 + offset + 1 + var dict: [String: String] = [:] + for (i, name) in page.header.enumerated() where i < row.count { + // Condition fields capture the grade plus its tooltip + // description; keep the JSONB mirror as clean as the + // snapshot column (grade through the closing paren). + if name.hasSuffix("_condition"), let close = row[i].range(of: ")") { + dict[name] = String(row[i][.. (sql: String, snapshotCount: Int, skipped: Int) { + func col(_ name: String) -> Int? { header.firstIndex(of: name) } + guard let itemIdx = col("item_id"), let relIdx = col("release_id") else { return ("", 0, 0) } + let sellerIdIdx = col("seller_id"), sellerNameIdx = col("seller_username") + let sellerNameAltIdx = col("seller_username_alt") + let titleIdx = col("title"), urlIdx = col("listing_url") + let mediaIdx = col("media_condition"), sleeveIdx = col("sleeve_condition") + let curIdx = col("price_currency"), priceIdx = col("price_value") + let shipIdx = col("shipping") + + func value(_ row: [String], _ idx: Int?) -> String? { + guard let idx, idx < row.count else { return nil } + let v = row[idx].trimmingCharacters(in: .whitespaces) + return v.isEmpty ? nil : v + } + + // "Near Mint (NM or M-) A nearly perfect record…" → keep through the ")". + func condition(_ s: String?) -> String? { + guard let s else { return nil } + if let close = s.range(of: ")") { return String(s[.. String? { + guard let s, let d = Double(s) else { return nil } + return String(format: "%.2f", d) + } + + func firstDecimal(_ s: String?) -> String? { + guard let s, let r = s.range(of: #"\d+(\.\d+)?"#, options: .regularExpression) else { return nil } + return numeric(String(s[r])) + } + + var sellers: [Int64: String] = [:] + var snapshots: [Int64: String] = [:] // item_id → VALUES tuple (dedup within batch) + var skipped = 0 + + for row in rows { + guard let itemId = value(row, itemIdx).flatMap({ Int64($0) }), + let releaseId = value(row, relIdx).flatMap({ Int64($0) }) else { + skipped += 1 + continue + } + // Only reference a seller_id the snapshot's FK can resolve: upsert + // the seller whenever its id is present (falling back to the + // co-located data-username, then a derived name), and NULL the + // snapshot's seller_id when no id was found. + var linkedSellerId: Int64? + if let sellerId = value(row, sellerIdIdx).flatMap({ Int64($0) }) { + let name = value(row, sellerNameIdx) + ?? value(row, sellerNameAltIdx) + ?? "seller_\(sellerId)" + sellers[sellerId] = name + linkedSellerId = sellerId + } + let tuple = """ + (\(itemId), \(releaseId), \(linkedSellerId.map(String.init) ?? "NULL"), \ + \(lit(value(row, titleIdx))), \(lit(value(row, urlIdx))), \ + \(lit(condition(value(row, mediaIdx)))), \(lit(condition(value(row, sleeveIdx)))), \ + \(lit(value(row, curIdx))), \(numeric(value(row, priceIdx)) ?? "NULL"), \ + \(firstDecimal(value(row, shipIdx)) ?? "NULL")) + """ + snapshots[itemId] = tuple + } + guard !snapshots.isEmpty else { return ("", 0, skipped) } + + var sql = "" + if !sellers.isEmpty { + let sellerValues = sellers + .sorted { $0.key < $1.key } + .map { "(\($0.key), \(lit(String($0.value.prefix(100)))))" } + .joined(separator: ",\n") + sql += """ + INSERT INTO discogs_sellers (seller_id, username) VALUES + \(sellerValues) + ON CONFLICT (seller_id) DO UPDATE SET username = EXCLUDED.username, last_scraped_at = now(); + + """ + } + let snapshotValues = snapshots.sorted { $0.key < $1.key }.map { $0.value }.joined(separator: ",\n") + sql += """ + INSERT INTO marketplace_snapshots + (item_id, release_id, seller_id, title, listing_url, media_condition, + sleeve_condition, price_currency, price_value, shipping_value) VALUES + \(snapshotValues) + ON CONFLICT (item_id) DO UPDATE SET + last_seen = now(), + price_value = EXCLUDED.price_value, + shipping_value = EXCLUDED.shipping_value, + media_condition = EXCLUDED.media_condition, + sleeve_condition = EXCLUDED.sleeve_condition, + is_active = TRUE; + + """ + return (sql, snapshots.count, skipped) + } + + private static func lit(_ s: String?) -> String { + guard let s else { return "NULL" } + let cleaned = s.replacingOccurrences(of: "\0", with: "") + return "'" + cleaned.replacingOccurrences(of: "'", with: "''") + "'" + } + + // MARK: - psql runner + + private static let workQueue = DispatchQueue(label: "textplus.postgres") + private static let timeoutSeconds: TimeInterval = 60 + + private static func runSQL(uri: String, sql: String, + completion: @escaping (Result) -> Void) { + guard let psql = psqlPath else { + DispatchQueue.main.async { + completion(.failure(PGError(message: "psql not found — install with: brew install postgresql@17"))) + } + return + } + workQueue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: psql) + process.arguments = ["-X", "-q", "-A", "-t", "-v", "ON_ERROR_STOP=1", uri] + + let inPipe = Pipe(), outPipe = Pipe(), errPipe = Pipe() + process.standardInput = inPipe + process.standardOutput = outPipe + process.standardError = errPipe + + do { + try process.run() + } catch { + DispatchQueue.main.async { completion(.failure(PGError(message: error.localizedDescription))) } + return + } + + let watchdog = DispatchWorkItem { [weak process] in + guard let process, process.isRunning else { return } + process.terminate() + } + DispatchQueue.global().asyncAfter(deadline: .now() + timeoutSeconds, execute: watchdog) + + // Drain stdout/stderr concurrently while we feed stdin, so a large + // script can't deadlock on a full pipe in either direction. + var outData = Data() + var errData = Data() + let group = DispatchGroup() + group.enter() + DispatchQueue.global(qos: .utility).async { + outData = outPipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + group.enter() + DispatchQueue.global(qos: .utility).async { + errData = errPipe.fileHandleForReading.readDataToEndOfFile() + group.leave() + } + + let writer = inPipe.fileHandleForWriting + do { + try writer.write(contentsOf: Data(sql.utf8)) + try writer.close() + } catch { + // Broken pipe (psql died early) — fall through to read its stderr. + try? writer.close() + } + + group.wait() + process.waitUntilExit() + watchdog.cancel() + + let out = String(decoding: outData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + let err = String(decoding: errData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + let status = process.terminationStatus + + DispatchQueue.main.async { + if status == 0 { + completion(.success(out)) + } else { + let detail = err.isEmpty ? "psql exited with status \(status)" : err + completion(.failure(PGError(message: detail))) + } + } + } + } +} + +private extension Array { + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { Array(self[$0.. Bool { + url.range(of: urlPattern, options: [.regularExpression, .caseInsensitive]) != nil + } + + static let discogsMarketplace = ScrapeProfile( + name: "discogs_marketplace", + urlPattern: #"discogs\.com/(sell/list|sell/mywants|seller/[^/]+/profile)"#, + rowSelector: "tr.shortcut_navigable", + fields: [ + ("item_id", "a.cart-button@data-item-id"), + ("release_id", "tr@data-release-id"), + ("title", "a.item_description_title"), + ("listing_url", "a.item_description_title@href"), + ("media_condition", ".item_condition span[!class]"), + ("sleeve_condition", "span.item_sleeve_condition"), + ("seller_username", ".seller_block a"), + ("seller_id", "li.seller_mywants@data-seller-id"), + // Co-located with seller_id on the same element — a reliable + // username fallback so a seller is never referenced unmapped. + ("seller_username_alt", "li.seller_mywants@data-username"), + ("price_value", "span.price@data-pricevalue"), + ("price_currency", "span.price@data-currency"), + ("shipping", "span.item_shipping"), + ] + ) + + static let builtIn: [ScrapeProfile] = [discogsMarketplace] + + static func matching(url: String?) -> ScrapeProfile? { + guard let url else { return nil } + return builtIn.first { $0.matchesURL(url) } + } +} + +struct DetectedPage { + let url: String? + let html: String +} + +enum PageDetector { + /// Recognizes the "URL on the first line, then pasted HTML" convention + /// (URL optional). Returns nil when the document doesn't look like HTML. + static func detect(in text: String) -> DetectedPage? { + var url: String? + var html = String(text.drop(while: { $0.isWhitespace })) + if html.hasPrefix("http://") || html.hasPrefix("https://") { + if let newline = html.firstIndex(of: "\n") { + let candidate = String(html[..) are parsed and stored: page + selector summary, and Discogs marketplace pages also upsert discogs_sellers / marketplace_snapshots. \(psql)" + } + + private func runPGTest() { + pgTesting = true + pgMessage = nil + let uri = pgURI.trimmingCharacters(in: .whitespaces) + PostgresStore.test(uri: uri.isEmpty ? PGSettings.defaultURI : uri) { result in + pgTesting = false + switch result { + case .success(let message): + pgSucceeded = true + pgMessage = message + case .failure(let error): + pgSucceeded = false + pgMessage = error.message + } + } + } + + private func invalidateTest() { + testGeneration += 1 + testMessage = nil + testing = false + } + + private func runTest() { + testGeneration += 1 + let generation = testGeneration + testing = true + testMessage = nil + SyncEngine.testConnection(remote: trimmedRemote, + port: portValue ?? 22, + dir: dir) { result in + guard generation == testGeneration else { return } + testing = false + switch result { + case .success(let message): + testSucceeded = true + testMessage = message + case .failure(let error): + testSucceeded = false + testMessage = error.message + } + } + } +} diff --git a/Sources/SyncEngine.swift b/Sources/SyncEngine.swift new file mode 100644 index 0000000..5cdd321 --- /dev/null +++ b/Sources/SyncEngine.swift @@ -0,0 +1,209 @@ +import Foundation + +enum SyncSettings { + static let enabledKey = "syncEnabled" + static let remoteKey = "syncRemote" + static let dirKey = "syncRemoteDir" + static let portKey = "syncPort" + + static var enabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) } + static var remote: String { + (UserDefaults.standard.string(forKey: remoteKey) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + static var dir: String { + (UserDefaults.standard.string(forKey: dirKey) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + static var port: Int { + let raw = (UserDefaults.standard.string(forKey: portKey) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let p = Int(raw), (1...65535).contains(p) else { return 22 } + return p + } + static var isConfigured: Bool { !remote.isEmpty } +} + +struct SyncError: Error { + let message: String +} + +enum SyncEngine { + /// Main-thread-only count of scp uploads that haven't finished yet. + private(set) static var inFlightUploads = 0 + /// Called on the main thread when the last in-flight upload finishes. + static var onIdle: (() -> Void)? + + private static let sshOptions = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=3", + "-o", "StrictHostKeyChecking=accept-new", + ] + + private static let watchdogSeconds: TimeInterval = 120 + + // All invocations run serially: rapid saves of the same file must never + // have two scp processes writing the same remote path at once. + private static let workQueue = DispatchQueue(label: "textplus.sync") + + /// Copy the file to the configured remote directory in the background. + /// Call from the main thread; the completion handler runs on the main queue. + static func upload(fileURL: URL, completion: @escaping (Result) -> Void) { + dispatchPrecondition(condition: .onQueue(.main)) + inFlightUploads += 1 + let destination = scpDestination(remote: SyncSettings.remote, dir: SyncSettings.dir) + let args = sshOptions + ["-P", String(SyncSettings.port), fileURL.path, destination] + run(tool: "/usr/bin/scp", args: args) { status, output, timedOut in + if status == 0 && !timedOut { + completion(.success(())) + } else { + completion(.failure(SyncError(message: friendlyMessage(tool: "scp", status: status, output: output, timedOut: timedOut)))) + } + inFlightUploads -= 1 + if inFlightUploads == 0 { onIdle?() } + } + } + + /// Check that we can reach the remote and that the directory exists. + static func testConnection(remote: String, port: Int, dir: String, + completion: @escaping (Result) -> Void) { + let trimmedDir = dir.trimmingCharacters(in: .whitespacesAndNewlines) + let probe = trimmedDir.isEmpty + ? "echo __OK__" + : "if [ -d \(probePath(trimmedDir)) ]; then echo __OK__; else echo __NODIR__; fi" + let args = sshOptions + ["-p", String(port), remote, probe] + run(tool: "/usr/bin/ssh", args: args) { status, output, timedOut in + if output.contains("__OK__") { + completion(.success("Connected — folder exists")) + } else if output.contains("__NODIR__") { + completion(.failure(SyncError(message: "Connected, but the remote folder doesn't exist"))) + } else { + completion(.failure(SyncError(message: friendlyMessage(tool: "ssh", status: status, output: output, timedOut: timedOut)))) + } + } + } + + // MARK: - Destination / path handling + + /// scp splits the remote spec at the first colon, so IPv6 literals + /// (and other hosts containing ':') must be bracketed. + private static func scpDestination(remote: String, dir: String) -> String { + var d = dir + if !d.isEmpty && !d.hasSuffix("/") { d += "/" } + var user = "" + var host = remote + if let at = remote.lastIndex(of: "@") { + user = String(remote[...at]) + host = String(remote[remote.index(after: at)...]) + } + if host.contains(":") && !host.hasPrefix("[") { + host = "[\(host)]" + } + return "\(user)\(host):\(d)" + } + + /// Quote the directory for the remote shell, but leave a leading ~ + /// unquoted so it expands — matching scp's SFTP-mode ~ expansion on upload. + private static func probePath(_ dir: String) -> String { + if dir == "~" { return "~" } + if dir.hasPrefix("~/") { return "~/" + shellQuoted(String(dir.dropFirst(2))) } + return shellQuoted(dir) + } + + private static func shellQuoted(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + // MARK: - Error reporting + + private static func friendlyMessage(tool: String, status: Int32, output: String, timedOut: Bool) -> String { + if timedOut { + return "Timed out after \(Int(watchdogSeconds)) seconds" + } + var message = output.isEmpty ? "\(tool) exited with status \(status)" : output + if message.contains("Permission denied") { + // The window subtitle shows only the first line, so the hint goes there. + let lines = message.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let hint = " — SSH key not available? Run 'ssh-add --apple-load-keychain' in Terminal," + + " or add 'UseKeychain yes' and 'AddKeysToAgent yes' to ~/.ssh/config" + message = (lines.first ?? message) + hint + if lines.count > 1 { + message += "\n" + lines.dropFirst().joined(separator: "\n") + } + } + return message + } + + // MARK: - Process plumbing + + private static func run(tool: String, args: [String], + completion: @escaping (Int32, String, Bool) -> Void) { + workQueue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: tool) + process.arguments = args + + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + process.standardInput = FileHandle.nullDevice + + do { + try process.run() + } catch { + DispatchQueue.main.async { completion(-1, error.localizedDescription, false) } + return + } + + // Watchdog: unstick a wedged transfer. Weak capture so the canceled + // work item doesn't pin the Process and pipe FDs until its deadline. + let timedOutFlag = AtomicFlag() + let watchdog = DispatchWorkItem { [weak process] in + guard let process, process.isRunning else { return } + timedOutFlag.set() + process.terminate() + } + DispatchQueue.global().asyncAfter(deadline: .now() + watchdogSeconds, execute: watchdog) + + // Drain both pipes concurrently so a full stderr buffer can't + // stall the child while we block on stdout. + var errData = Data() + let errDone = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .utility).async { + errData = errPipe.fileHandleForReading.readDataToEndOfFile() + errDone.signal() + } + let outData = outPipe.fileHandleForReading.readDataToEndOfFile() + errDone.wait() + process.waitUntilExit() + watchdog.cancel() + + let combined = (String(decoding: outData, as: UTF8.self) + "\n" + + String(decoding: errData, as: UTF8.self)) + .trimmingCharacters(in: .whitespacesAndNewlines) + let status = process.terminationStatus + let timedOut = timedOutFlag.get() + DispatchQueue.main.async { completion(status, combined, timedOut) } + } + } +} + +private final class AtomicFlag { + private let lock = NSLock() + private var value = false + + func set() { + lock.lock() + value = true + lock.unlock() + } + + func get() -> Bool { + lock.lock() + defer { lock.unlock() } + return value + } +} diff --git a/Sources/TextMarkup.swift b/Sources/TextMarkup.swift new file mode 100644 index 0000000..d07961b --- /dev/null +++ b/Sources/TextMarkup.swift @@ -0,0 +1,39 @@ +import AppKit + +/// Selection-aware Markdown insertion used by both the toolbar and the Format +/// menu. Wraps the selection (or inserts a placeholder), keeping the change on +/// the text view's own undo stack. +enum TextMarkup { + static func wrap(_ textView: NSTextView, prefix: String, suffix: String, placeholder: String) { + let range = textView.selectedRange() + let ns = textView.string as NSString + let selected = ns.substring(with: range) + let body = selected.isEmpty ? placeholder : selected + let replacement = prefix + body + suffix + guard textView.shouldChangeText(in: range, replacementString: replacement) else { return } + textView.replaceCharacters(in: range, with: replacement) + textView.didChangeText() + // Re-select the body so the user can keep typing or toggle again. + let bodyStart = range.location + (prefix as NSString).length + textView.setSelectedRange(NSRange(location: bodyStart, length: (body as NSString).length)) + } + + /// Prefix every line touched by the selection (headings, lists, quotes). + static func linePrefix(_ textView: NSTextView, prefix: String) { + let ns = textView.string as NSString + let lineRange = ns.lineRange(for: textView.selectedRange()) + let block = ns.substring(with: lineRange) + let prefixed = block + .components(separatedBy: "\n") + .enumerated() + .map { idx, line in + // Don't add a stray prefix to a trailing empty line from the split. + (line.isEmpty && idx > 0) ? line : prefix + line + } + .joined(separator: "\n") + guard textView.shouldChangeText(in: lineRange, replacementString: prefixed) else { return } + textView.replaceCharacters(in: lineRange, with: prefixed) + textView.didChangeText() + textView.setSelectedRange(NSRange(location: lineRange.location, length: (prefixed as NSString).length)) + } +} diff --git a/Sources/WorkspaceController.swift b/Sources/WorkspaceController.swift new file mode 100644 index 0000000..765b2f2 --- /dev/null +++ b/Sources/WorkspaceController.swift @@ -0,0 +1,564 @@ +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) } +} diff --git a/Sources/main.swift b/Sources/main.swift new file mode 100644 index 0000000..cb90593 --- /dev/null +++ b/Sources/main.swift @@ -0,0 +1,10 @@ +import AppKit + +// Subprocess pipes (scp, psql) can break mid-write; don't let SIGPIPE kill the app. +signal(SIGPIPE, SIG_IGN) + +let app = NSApplication.shared +let appDelegate = AppDelegate() +app.delegate = appDelegate +app.setActivationPolicy(.regular) +app.run() diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..3fd062d --- /dev/null +++ b/build.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Build TextPlus.app — run: ./build.sh +set -euo pipefail +cd "$(dirname "$0")" + +APP="TextPlus.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +swiftc -O -module-name TextPlus \ + Sources/*.swift \ + -o "$APP/Contents/MacOS/TextPlus" + +cp Info.plist "$APP/Contents/Info.plist" +if [ -f Resources/AppIcon.icns ]; then + cp Resources/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" +fi + +codesign --force --sign - "$APP" +echo "Built $APP" diff --git a/demo.md b/demo.md new file mode 100644 index 0000000..a150175 --- /dev/null +++ b/demo.md @@ -0,0 +1,23 @@ +# TextPlus + +A **plaintext** editor that grew *teeth*: themes, live Markdown preview, a +paint canvas, and a pipeline that parses HTML and ships it to Postgres. + +## What's in the box + +- Five editor themes with a line-number gutter +- Live Markdown preview (you're looking at the source) +- A vector **paint canvas** — pen, shapes, arrows, text +- `scp`-on-save to any Mac on your tailnet +- HTML selector parsing → `marketplace_snapshots` + +> Toggle the view with the segmented control top-left, or ⌃⌘1 / ⌃⌘2 / ⌃⌘3. + +### A code sample + +```swift +let canvas = DrawCanvasView() +canvas.tool = .arrow +``` + +Visit [the repo](https://example.com) and press ⌘B to **bold** a selection. diff --git a/docs/BOARD_DESIGN.md b/docs/BOARD_DESIGN.md new file mode 100644 index 0000000..c5eb0e0 --- /dev/null +++ b/docs/BOARD_DESIGN.md @@ -0,0 +1,75 @@ +# Freeform Board — implementation blueprint + +Vetted by a 13-agent design panel (winner: reuse-robust, with freeform-ux box-cards +and the editorScrollView-nesting fix grafted in). + +## Core decisions +- **Board = 4th WorkspaceController.Mode** (`editor`, `split`, `board`). Canvas mode is + ABSORBED into the board — the board's shapes layer is the drawing surface. +- **The magnifying `boardScroll` is the only scroll view.** Cards are bare views + (NSTextView / NSImageView / CALayer box). NEVER nest `editorScrollView` inside it. +- **Main card hosts the document's real `textView`** (reparented bare, no scroll view), + so dirty/undo/find/line-col all keep working. Its text = the `.txt` bytes (never + serialized to the sidecar — no second copy can diverge). +- **Secondary text cards** are board-only annotations; their text lives in the sidecar, + not the `.txt`. +- **Clean boxes** = `BoardCard(kind: .box)` CALayer cards (fill `accent@0.18`, stroke + `accent`, cornerRadius 8). Freehand pen/shapes stay on the reused `DrawCanvasView`. + +## View hierarchy (Sources/BoardContainer.swift) +``` +BoardContainerView : NSView (sibling of splitView; same 4 pins; isHidden=true) + ├─ palette : NSStackView (left, fixed width) Select/Box/Pen/Line/Arrow/Oval/Eraser, + │ stroke well, width slider, New Text Card, Add Image…, Portrait/Landscape, + │ zoom-to-fit, undo/redo, export PNG, send-to-tailnet (pinned, NOT zoomed) + └─ boardScroll : NSScrollView allowsMagnification, min .15 / max 4 + └─ boardView : BoardView (NSView, isFlipped, wantsLayer, world 8000×6000, dot grid) + ├─ shapes : DrawCanvasView (reused, canvasBackground=.clear, bottom) + ├─ cardViews : [CardView] by z (TextCardView / ImageCardView / BoxCardView) + └─ selectionOverlay : SelectionOverlayView (8 handles, top, forwards events) +``` + +## Data model (Sources/Board.swift), Codable +- `BoardCardKind { mainText, text, image, box }` +- `PageOrientation { portrait(612×792), landscape(792×612) }` +- `BoardCard { id, kind, frame(world), z, text?, orientation?, imageFile?, fillHex?, strokeHex?, cornerRadius?, title? }` +- `BoardDoc { version, cards, shapes: DrawCanvasView.CanvasDoc, magnification, scrollOrigin, boardBackgroundHex? }` +- Hand-written `init(from:)` that `try?`-decodes each field with a default → missing/old + sidecar never throws. + +## Persistence: bundle dir `foo.txt.board/` +- `board.json` (atomic write via replaceItemAt, pretty+sortedKeys) +- `img-.png` assets (referenced by filename; bytes never in JSON; GC orphans on save) +- `preview.png` (derived, for send-to-tailnet) +- Load after Document.swift:41; save after Document.swift:121, all `try?`-guarded so a + sidecar failure NEVER fails the plaintext save. Main card text is never written here. +- Migration: if `foo.txt.board/` absent but `foo.txt.canvas.json` exists, import its + CanvasDoc into `BoardDoc.shapes` (one-way, leaves the old file). + +## Focus transition +- Enter: double-click text card OR Return on selection → record preFocus mag/visibleRect, + raise z, animate `boardScroll.animator().setMagnification(clamp 1.0–1.5, centeredAt:)`, + textView editable+selectable, makeFirstResponder(textView). +- Exit: Esc / click empty / select other / leave mode → breakUndoCoalescing(), copy + secondary card text back + updateChangeCount, editable=false, makeFirstResponder(boardView), + animate back. +- **Mandatory:** CardView.hitTest returns self while unfocused (a non-selectable NSTextView + still returns self for a plain click). Shapes layer hit-tests only when a draw tool active. + +## Must-handle pitfalls +1. Reparent only the bare textView, never editorScrollView (the #1 sleeper bug). +2. rulersVisible=false in board; restore on exit; keep editorScrollView alive. +3. Gate applyWrap when mode==.board (it reads editorScrollView.contentSize). +4. Return editor to splitView via insertArrangedSubview(at:0), never add (pane order). +5. NSScrollView.allowsMagnification only — no manual CGAffineTransform. convert(from:nil) keeps working. +6. CardView hitTest override (above). +7. Centralize textView reparenting in attachTextViewToBoard()/detachTextViewToEditor(); one superview owns it; never recreate it. Test editor→board→editor→split round-trip FIRST. +8. shapes DrawCanvasView canvasBackground=.clear (it opaque-fills bounds otherwise). +9. Two undo managers (text=document, structural=boardView); every structural mutation also updateChangeCount(.changeDone). +10. Board PNG export at magnification 1.0 over the union of content, not the 8000×6000 world. +11. Re-resolve CALayer cgColors in applyAppearance() (they don't auto-update on appearance change). +12. saveBoardSidecar wrapped in try?, after super.save succeeds. + +## MVP cuts (defer) +connectors, marquee multi-select, alignment guides (keep grid-snap), card snapshotting, +interleaved shape/card z, multi-page-into-one-.txt, JSON forward-merge, QuickLook plugin.