import Cocoa import Combine import SwiftUI import UniformTypeIdentifiers extension Notification.Name { static let focusEditor = Notification.Name("focusEditor") static let focusTitle = Notification.Name("focusTitle") static let newNoteSeeded = Notification.Name("newNoteSeeded") } class WindowController { let window: NSWindow let appState: AppState init(window: NSWindow, appState: AppState) { self.window = window self.appState = appState } } class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation { var window: NSWindow! var appState: AppState! private var titleCancellable: AnyCancellable? private var textCancellable: AnyCancellable? private var titleBarView: TitleBarView? private var focusTitleObserver: NSObjectProtocol? private var windowControllers: [WindowController] = [] /// Writes the viewport's current text to the notes directory on a /// tight interval. Deliberately bypasses `appState.documentText` — the /// Combine sink on that property pushes text back into the viewport /// via `vp.setText`, which rebuilds viewport state and clears the /// eval overlay. By writing straight to disk, autosave can't disturb /// what the user sees. private var autosaveTimer: Timer? /// Hash of the viewport text the last time autosave actually wrote to /// disk. The 100ms timer compares against this and skips the write if /// nothing has changed — without this gate, autosave rewrites the /// entire file every tick (~500 KB/s sustained on a 50 KB doc, which /// macOS flags as a disk-writes throttle event). private var lastAutosavedHash: Int? private var viewport: IcedViewportView? { window?.contentView as? IcedViewportView } func applicationDidFinishLaunching(_ notification: Notification) { _ = ConfigManager.shared appState = AppState() let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800)) viewport.autoresizingMask = [.width, .height] window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false ) window.isReleasedWhenClosed = false window.titlebarAppearsTransparent = true window.titleVisibility = .hidden window.backgroundColor = Theme.current.base window.title = "Acord" window.contentView = viewport window.center() window.setFrameAutosaveName("AcordMainWindow") window.makeKeyAndOrderFront(nil) applyThemeAppearance() setupTitleBar() setupMenuBar() observeDocumentTitle() observeDocumentText() syncThemeToViewport() syncGutterPrefsToViewport() startAutosaveTimer() DocumentBrowserController.shared = DocumentBrowserController(appState: appState) NotificationCenter.default.addObserver( self, selector: #selector(settingsDidChange), name: .settingsChanged, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleNewNoteSeeded), name: .newNoteSeeded, object: nil ) if let url = pendingOpenURLs.first { pendingOpenURLs = [] appState.loadNoteFromFile(url) } } private var pendingOpenURLs: [URL] = [] func application(_ application: NSApplication, open urls: [URL]) { guard let url = urls.first else { return } if appState != nil { appState.loadNoteFromFile(url) } else { pendingOpenURLs = [url] } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } // Runs before AppKit tears the window down. We must front-run the window // teardown so the Rust-backed viewport releases its wgpu/Metal resources // while the NSView + CAMetalLayer it holds raw pointers to are still // alive. `applicationWillTerminate` is too late: by the time that fires, // AppKit has already started deallocating the window/contentView graph // and the delegate can no longer safely read `self.window`. func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { // Pull out any unsaved text before tearing down. `getText` refreshes // the viewport's own `cachedText`, so later reads during teardown // can fall back to it if the handle is already gone. syncTextFromViewport() appState.saveNote() // Explicit, ordered teardown of every viewport we own, while the // views + window graph are still fully alive. if let vp = viewport { vp.teardown() } for controller in windowControllers { if let vp = controller.window.contentView as? IcedViewportView { vp.teardown() } } // Drop strong refs so AppKit doesn't try to replay anything through // the delegate during its own terminate phases. titleCancellable = nil textCancellable = nil if let observer = focusTitleObserver { NotificationCenter.default.removeObserver(observer) focusTitleObserver = nil } NotificationCenter.default.removeObserver(self) return .terminateNow } // MARK: - Menu bar private func setupMenuBar() { let mainMenu = NSMenu() mainMenu.addItem(buildAppMenu()) mainMenu.addItem(buildFileMenu()) mainMenu.addItem(buildEditMenu()) mainMenu.addItem(buildRenderMenu()) mainMenu.addItem(buildViewMenu()) mainMenu.addItem(buildWindowMenu()) NSApp.mainMenu = mainMenu } private func buildAppMenu() -> NSMenuItem { let item = NSMenuItem() let menu = NSMenu() menu.addItem(withTitle: "About Acord", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") menu.addItem(.separator()) let settingsItem = NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",") settingsItem.target = self menu.addItem(settingsItem) menu.addItem(.separator()) menu.addItem(withTitle: "Quit Acord", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") item.submenu = menu return item } private func buildFileMenu() -> NSMenuItem { let item = NSMenuItem() let menu = NSMenu(title: "File") let newWindowItem = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "n") newWindowItem.target = self menu.addItem(newWindowItem) let newNoteItem = NSMenuItem(title: "New Note", action: #selector(newNote), keyEquivalent: "N") newNoteItem.keyEquivalentModifierMask = [.command, .shift] newNoteItem.target = self menu.addItem(newNoteItem) let openItem = NSMenuItem(title: "Open...", action: #selector(openNote), keyEquivalent: "o") openItem.target = self menu.addItem(openItem) menu.addItem(.separator()) let saveItem = NSMenuItem(title: "Save", action: #selector(saveNote), keyEquivalent: "s") saveItem.target = self menu.addItem(saveItem) let saveAsItem = NSMenuItem(title: "Save As...", action: #selector(saveNoteAs), keyEquivalent: "S") saveAsItem.target = self menu.addItem(saveAsItem) menu.addItem(.separator()) let exportCrateItem = NSMenuItem(title: "Export as Rust Library...", action: #selector(exportCrate), keyEquivalent: "E") exportCrateItem.keyEquivalentModifierMask = [.command, .shift] exportCrateItem.target = self menu.addItem(exportCrateItem) menu.addItem(.separator()) let openStorageItem = NSMenuItem(title: "Open Storage Directory", action: #selector(openStorageDirectory), keyEquivalent: "") openStorageItem.target = self menu.addItem(openStorageItem) item.submenu = menu return item } private func buildEditMenu() -> NSMenuItem { let item = NSMenuItem() let menu = NSMenu(title: "Edit") menu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") menu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") menu.addItem(.separator()) menu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") menu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") menu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") menu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") menu.addItem(.separator()) let boldItem = NSMenuItem(title: "Bold", action: #selector(boldSelection), keyEquivalent: "b") boldItem.target = self menu.addItem(boldItem) let italicItem = NSMenuItem(title: "Italic", action: #selector(italicizeSelection), keyEquivalent: "i") italicItem.target = self menu.addItem(italicItem) menu.addItem(.separator()) let tableItem = NSMenuItem(title: "Insert Table", action: #selector(insertTable), keyEquivalent: "t") tableItem.target = self menu.addItem(tableItem) let evalItem = NSMenuItem(title: "Smart Eval", action: #selector(smartEval), keyEquivalent: "e") evalItem.target = self menu.addItem(evalItem) menu.addItem(.separator()) let findItem = NSMenuItem(title: "Find...", action: #selector(NSTextView.performFindPanelAction(_:)), keyEquivalent: "f") findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue) menu.addItem(findItem) menu.addItem(.separator()) let formatItem = NSMenuItem(title: "Format Document", action: #selector(formatDocument), keyEquivalent: "F") formatItem.keyEquivalentModifierMask = [.command, .shift] formatItem.target = self menu.addItem(formatItem) menu.addItem(.separator()) menu.addItem(buildAutoPairMenu()) item.submenu = menu return item } private func buildAutoPairMenu() -> NSMenuItem { let item = NSMenuItem(title: "Auto Pair", action: nil, keyEquivalent: "") let menu = NSMenu(title: "Auto Pair") let pairs: [(String, UInt32)] = [ ("Parens ( )", 1), ("Brackets [ ]", 2), ("Braces { }", 4), ("Single quotes ' '", 8), ("Double quotes \" \"", 16), ("Backticks ` `", 32), ] let flags = ConfigManager.shared.autoPairFlags for (label, bit) in pairs { let mi = NSMenuItem(title: label, action: #selector(toggleAutoPair(_:)), keyEquivalent: "") mi.target = self mi.tag = Int(bit) mi.state = (flags & bit) != 0 ? .on : .off menu.addItem(mi) } item.submenu = menu return item } @objc private func toggleAutoPair(_ sender: NSMenuItem) { let bit = UInt32(sender.tag) var flags = ConfigManager.shared.autoPairFlags flags ^= bit ConfigManager.shared.autoPairFlags = flags sender.state = (flags & bit) != 0 ? .on : .off viewport?.setAutoPairFlags(flags) } private func buildRenderMenu() -> NSMenuItem { let item = NSMenuItem() let menu = NSMenu(title: "Render") let modesHeader = NSMenuItem(title: "Modes", action: nil, keyEquivalent: "") modesHeader.isEnabled = false menu.addItem(modesHeader) let liveItem = NSMenuItem(title: "Live", action: #selector(setLiveMode), keyEquivalent: "") liveItem.target = self menu.addItem(liveItem) let editorItem = NSMenuItem(title: "Editor", action: #selector(setEditorMode), keyEquivalent: "") editorItem.target = self menu.addItem(editorItem) let viewItem = NSMenuItem(title: "View", action: #selector(setViewMode), keyEquivalent: "") viewItem.target = self menu.addItem(viewItem) item.submenu = menu return item } private func buildViewMenu() -> NSMenuItem { let item = NSMenuItem() let menu = NSMenu(title: "View") let toggleItem = NSMenuItem(title: "Document Browser", action: #selector(toggleBrowser), keyEquivalent: "b") toggleItem.keyEquivalentModifierMask = .control toggleItem.target = self menu.addItem(toggleItem) menu.addItem(.separator()) let zoomInItem = NSMenuItem(title: "Zoom In", action: #selector(zoomIn), keyEquivalent: "=") zoomInItem.target = self menu.addItem(zoomInItem) let zoomOutItem = NSMenuItem(title: "Zoom Out", action: #selector(zoomOut), keyEquivalent: "-") zoomOutItem.target = self menu.addItem(zoomOutItem) let actualSizeItem = NSMenuItem(title: "Actual Size", action: #selector(zoomReset), keyEquivalent: "0") actualSizeItem.target = self menu.addItem(actualSizeItem) item.submenu = menu return item } private func buildWindowMenu() -> NSMenuItem { let item = NSMenuItem() let menu = NSMenu(title: "Window") menu.addItem(withTitle: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m") menu.addItem(withTitle: "Zoom", action: #selector(NSWindow.zoom(_:)), keyEquivalent: "") item.submenu = menu NSApp.windowsMenu = menu return item } // MARK: - Actions @objc private func newNote() { appState.newNote() } @objc private func newWindow() { let state = AppState() let viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800)) viewport.autoresizingMask = [.width, .height] let win = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false ) win.isReleasedWhenClosed = false win.titlebarAppearsTransparent = true win.titleVisibility = .hidden win.backgroundColor = Theme.current.base win.title = "Acord" win.contentView = viewport win.center() win.makeKeyAndOrderFront(nil) let controller = WindowController(window: win, appState: state) windowControllers.append(controller) } @objc private func openStorageDirectory() { let dir = ConfigManager.shared.autoSaveDirectory let url = URL(fileURLWithPath: dir, isDirectory: true) NSWorkspace.shared.open(url) } @objc private func boldSelection() { viewport?.sendCommand(1) } @objc private func italicizeSelection() { viewport?.sendCommand(2) } @objc private func insertTable() { viewport?.sendCommand(3) } @objc private func smartEval() { viewport?.sendCommand(4) } @objc private func openNote() { let panel = NSOpenPanel() panel.allowedContentTypes = Self.supportedContentTypes panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.beginSheetModal(for: window) { [weak self] response in guard response == .OK, let url = panel.url else { return } self?.appState.loadNoteFromFile(url) } } @objc private func saveNote() { syncTextFromViewport() if appState.currentFileURL != nil { appState.saveNote() } else { saveNoteAs() } } @objc private func saveNoteAs() { syncTextFromViewport() let panel = NSSavePanel() panel.allowedContentTypes = Self.supportedContentTypes panel.nameFieldStringValue = defaultFilename() if let url = appState.currentFileURL { panel.directoryURL = url.deletingLastPathComponent() panel.nameFieldStringValue = url.lastPathComponent } panel.beginSheetModal(for: window) { [weak self] response in guard response == .OK, let url = panel.url else { return } self?.appState.saveNoteToFile(url) } } @objc private func exportCrate() { syncTextFromViewport() guard let w = window, let vp = w.contentView as? IcedViewportView, let handle = vp.viewportHandle else { return } let panel = NSSavePanel() panel.title = "Export as Rust Library" panel.message = "Choose a location and name for your exported crate" panel.prompt = "Export" panel.nameFieldLabel = "Crate name:" panel.nameFieldStringValue = defaultCrateName() panel.canCreateDirectories = true panel.beginSheetModal(for: w) { response in guard response == .OK, let url = panel.url else { return } let parentDir = url.deletingLastPathComponent().path let name = url.lastPathComponent parentDir.withCString { pd in name.withCString { n in if let cstr = viewport_export_crate(handle, pd, n) { let resultPath = String(cString: cstr) viewport_free_string(cstr) self.notifyExportComplete(at: resultPath) } else { self.notifyExportFailed() } } } } } private func defaultCrateName() -> String { let firstLine = appState.documentText .components(separatedBy: "\n").first? .trimmingCharacters(in: .whitespaces) ?? "" let stripped = firstLine.replacingOccurrences( of: "^#+\\s*", with: "", options: .regularExpression ) let words = stripped.split(separator: " ").prefix(2).joined(separator: "-") let sanitized = words.lowercased() .map { $0.isLetter || $0.isNumber || $0 == "-" ? String($0) : "" }.joined() return sanitized.isEmpty ? "my-note" : sanitized } private func notifyExportComplete(at path: String) { let alert = NSAlert() alert.messageText = "Export complete" alert.informativeText = "Crate written to:\n\(path)\n\nCheck the README for build and install instructions." alert.addButton(withTitle: "Reveal in Finder") alert.addButton(withTitle: "OK") if alert.runModal() == .alertFirstButtonReturn { NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) } } private func notifyExportFailed() { let alert = NSAlert() alert.messageText = "Export failed" alert.informativeText = "Could not export the note. Check the folder permissions and that the crate name doesn't collide with an existing folder." alert.addButton(withTitle: "OK") alert.runModal() } private func defaultFilename() -> String { if let url = appState.currentFileURL { return url.lastPathComponent } let firstLine = appState.documentText .components(separatedBy: "\n").first? .trimmingCharacters(in: .whitespaces) ?? "" let stripped = firstLine.replacingOccurrences( of: "^#+\\s*", with: "", options: .regularExpression ) let trimmed = stripped.trimmingCharacters(in: .whitespaces) let ext = extensionForFormat(appState.currentFileFormat) guard !trimmed.isEmpty, trimmed != "Untitled" else { return "note.\(ext)" } let sanitized = trimmed.map { "/:\\\\".contains($0) ? "-" : String($0) }.joined() return sanitized.prefix(80) + ".\(ext)" } private func extensionForFormat(_ format: FileFormat) -> String { switch format { case .markdown: return "md" case .csv: return "csv" case .json: return "json" case .toml: return "toml" case .yaml: return "yaml" case .xml: return "xml" case .svg: return "svg" case .rust: return "rs" case .c: return "c" case .cpp: return "cpp" case .objc: return "m" case .javascript: return "js" case .typescript: return "ts" case .jsx: return "jsx" case .tsx: return "tsx" case .html: return "html" case .css: return "css" case .scss: return "scss" case .less: return "less" case .python: return "py" case .go: return "go" case .ruby: return "rb" case .php: return "php" case .lua: return "lua" case .shell: return "sh" case .java: return "java" case .kotlin: return "kt" case .swift: return "swift" case .zig: return "zig" case .sql: return "sql" case .makefile: return "mk" case .dockerfile: return "Dockerfile" case .config: return "conf" case .lock: return "lock" case .plainText, .unknown: return "txt" } } private static let supportedContentTypes: [UTType] = { let extensions = [ "md", "markdown", "mdown", "csv", "json", "toml", "yaml", "yml", "xml", "svg", "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "js", "jsx", "ts", "tsx", "html", "htm", "css", "scss", "less", "py", "go", "rb", "php", "lua", "sh", "bash", "zsh", "fish", "java", "kt", "kts", "swift", "zig", "sql", "mk", "ini", "cfg", "conf", "env", "lock", "txt", "text", "log" ] var types: [UTType] = [.plainText] for ext in extensions { if let t = UTType(filenameExtension: ext) { types.append(t) } } return Array(Set(types)) }() func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { let mode = viewport?.renderMode() ?? 0 switch menuItem.action { case #selector(setLiveMode): menuItem.state = mode == 0 ? .on : .off case #selector(setEditorMode): menuItem.state = mode == 1 ? .on : .off case #selector(setViewMode): menuItem.state = mode == 2 ? .on : .off default: break } return true } @objc private func setLiveMode() { viewport?.sendCommand(11) } @objc private func setEditorMode() { viewport?.sendCommand(12) } @objc private func handleNewNoteSeeded() { viewport?.sendCommand(12) } @objc private func setViewMode() { viewport?.sendCommand(13) } @objc private func formatDocument() { viewport?.sendCommand(10) } @objc private func openSettings() { SettingsWindowController.show() } @objc private func settingsDidChange() { window.backgroundColor = Theme.current.base syncThemeToViewport() syncGutterPrefsToViewport() window.contentView?.needsDisplay = true } private func syncThemeToViewport() { let mode = ConfigManager.shared.themeMode let name: String switch mode { case "dark": name = "kicad" case "light": name = "latte" default: let appearance = NSApp.effectiveAppearance let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua name = isDark ? "kicad" : "latte" } viewport?.setTheme(name) } private func syncGutterPrefsToViewport() { viewport?.setLineIndicator(ConfigManager.shared.lineIndicatorMode) viewport?.setGutterRainbow(ConfigManager.shared.gutterRainbow) viewport?.setAutoPairFlags(ConfigManager.shared.autoPairFlags) } @objc private func toggleBrowser() { DocumentBrowserController.shared?.toggle() } @objc private func zoomIn() { if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow { browser.browserState.scaleUp() return } ConfigManager.shared.zoomLevel += 1 NotificationCenter.default.post(name: .settingsChanged, object: nil) } @objc private func zoomOut() { if let browser = DocumentBrowserController.shared, browser.window.isKeyWindow { browser.browserState.scaleDown() return } let current = ConfigManager.shared.zoomLevel if 11 + current > 8 { ConfigManager.shared.zoomLevel -= 1 NotificationCenter.default.post(name: .settingsChanged, object: nil) } } @objc private func zoomReset() { ConfigManager.shared.zoomLevel = 0 NotificationCenter.default.post(name: .settingsChanged, object: nil) } private func setupTitleBar() { let accessory = TitleBarAccessoryController() window.addTitlebarAccessoryViewController(accessory) let tbv = accessory.titleView tbv.onCommit = { [weak self] rawTitle in guard let self = self else { return } // Only drop the document's first line if it actually IS a title // (starts with `#`). Normalize whatever the user typed in the // title bar to a `# ` prefix so the saved markdown is valid. let trimmed = rawTitle.trimmingCharacters(in: .whitespaces) let normalizedTitle: String if trimmed.isEmpty { normalizedTitle = "" } else if trimmed.hasPrefix("#") { normalizedTitle = trimmed } else { normalizedTitle = "# " + trimmed } let lines = self.appState.documentText.components(separatedBy: "\n") let firstIsTitle = lines.first .map { $0.trimmingCharacters(in: .whitespaces).hasPrefix("#") } ?? false let body: [String] = firstIsTitle ? Array(lines.dropFirst()) : lines let newLines: [String] if normalizedTitle.isEmpty { newLines = body } else { newLines = [normalizedTitle] + body } self.appState.documentText = newLines.joined(separator: "\n") } titleBarView = tbv focusTitleObserver = NotificationCenter.default.addObserver( forName: .focusTitle, object: nil, queue: .main ) { [weak self] _ in self?.titleBarView?.beginEditing() } } private func observeDocumentText() { textCancellable = appState.$documentText .receive(on: RunLoop.main) .sink { [weak self] text in guard let self = self, let vp = self.viewport else { return } // Idempotent: when the sync timer pulls text FROM the // viewport and assigns it to `documentText`, this sink // fires again and would push the identical text back in — // and `vp.setText` rebuilds viewport state, clearing eval // results. Skip the round-trip when vp already has it. if vp.getText() == text { return } vp.setText(text) } } private func syncTextFromViewport() { guard let w = window, let vp = w.contentView as? IcedViewportView else { return } let text = vp.getText() if !text.isEmpty || appState.documentText.isEmpty { appState.documentText = text } } /// 100ms autosave loop. Reads straight from the viewport and writes a /// file in the notes directory — no Combine publishers, no `setText`, /// no viewport-state rebuilds. The existing explicit flows (Cmd+S, /// note switch, quit) still route through `syncTextFromViewport` so /// `appState.documentText` stays current when Swift actually needs it. private func startAutosaveTimer() { autosaveTimer?.invalidate() autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in self?.persistViewportToNotesDir() } } private func persistViewportToNotesDir() { guard let w = window, let vp = w.contentView as? IcedViewportView else { return } let text = vp.getText() guard !AppState.isEffectivelyBlank(text) else { return } let hash = text.hashValue if hash == lastAutosavedHash { return } appState.writeAutosavedCopy(text: text) lastAutosavedHash = hash } private func observeDocumentTitle() { titleCancellable = appState.$documentText .receive(on: RunLoop.main) .sink { [weak self] text in guard let self = self else { return } let firstLine = text.components(separatedBy: "\n").first? .trimmingCharacters(in: .whitespaces) ?? "" let clean = firstLine.replacingOccurrences( of: "^#+\\s*", with: "", options: .regularExpression ) let displayTitle = clean.isEmpty ? "Acord" : String(clean.prefix(60)) self.window.title = displayTitle self.titleBarView?.title = firstLine } } }