import Cocoa import Combine class EditorWindow: NSObject { let window: NSWindow let appState: AppState let viewport: IcedViewportView private(set) var titleBarView: TitleBarView? private var titleCancellable: AnyCancellable? private var textCancellable: AnyCancellable? private var autosaveTimer: Timer? private var lastAutosavedHash: Int? private var observers: [NSObjectProtocol] = [] init(frameAutosaveName: String?) { self.appState = AppState() self.viewport = IcedViewportView(frame: NSRect(x: 0, y: 0, width: 1200, height: 800)) viewport.autoresizingMask = [.width, .height] self.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 if let autosave = frameAutosaveName { window.setFrameAutosaveName(autosave) } window.center() super.init() setupTitleBar() observeDocumentText() observeDocumentTitle() wireLoadedTextSync() syncThemeToViewport() syncGutterPrefsToViewport() syncSettingsToViewport() startAutosaveTimer() registerObservers() window.makeKeyAndOrderFront(nil) } func loadFromURL(_ url: URL) { appState.loadNoteFromFile(url) viewport.setLang(url.pathExtension) } // MARK: - Title bar 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 } 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 } // MARK: - Combine bindings private func observeDocumentText() { textCancellable = appState.$documentText .receive(on: RunLoop.main) .sink { [weak self] text in guard let self = self else { return } if self.viewport.getText() == text { return } self.viewport.setText(text) } } 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 } } private func wireLoadedTextSync() { appState.onLoadedTextChanged = { [weak self] text in guard let self = self else { return } if self.viewport.getText() != text { self.viewport.setText(text) } self.lastAutosavedHash = text.hashValue } appState.takeArchiveBytesFromViewport = { [weak self] in self?.viewport.takeSidecarBytes() } appState.applyArchiveBytesToViewport = { [weak self] data in self?.viewport.applySidecarBytes(data) } } // MARK: - Notifications private func registerObservers() { let settings = NotificationCenter.default.addObserver( forName: .settingsChanged, object: nil, queue: .main ) { [weak self] _ in self?.applySettingsChange() } observers.append(settings) let newNote = NotificationCenter.default.addObserver( forName: .newNoteSeeded, object: appState, queue: .main ) { [weak self] _ in self?.viewport.sendCommand(12) } observers.append(newNote) let focusTitle = NotificationCenter.default.addObserver( forName: .focusTitle, object: nil, queue: .main ) { [weak self] _ in guard let self = self, self.window.isKeyWindow else { return } self.titleBarView?.beginEditing() } observers.append(focusTitle) } private func applySettingsChange() { window.backgroundColor = Theme.current.base syncThemeToViewport() syncGutterPrefsToViewport() syncSettingsToViewport() window.contentView?.needsDisplay = true } // MARK: - Viewport sync func syncSettingsToViewport() { viewport.setSettingsView( themeMode: ConfigManager.shared.themeMode, lineIndicator: ConfigManager.shared.lineIndicatorMode, gutterRainbow: ConfigManager.shared.gutterRainbow, autoSaveDir: ConfigManager.shared.autoSaveDirectory ) } 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) } func syncGutterPrefsToViewport() { viewport.setLineIndicator(ConfigManager.shared.lineIndicatorMode) viewport.setGutterRainbow(ConfigManager.shared.gutterRainbow) viewport.setAutoPairFlags(ConfigManager.shared.autoPairFlags) } func syncTextFromViewport() { let text = viewport.getText() if !text.isEmpty || appState.documentText.isEmpty { appState.documentText = text } } // MARK: - Autosave loop private func startAutosaveTimer() { autosaveTimer?.invalidate() autosaveTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in self?.drainShellActions() self?.persistViewportToNotesDir() } } private func drainShellActions() { while let raw = viewport.takeShellAction() { let parts = raw.split(separator: ":", maxSplits: 1).map(String.init) let kind = parts[0] let value = parts.count > 1 ? parts[1] : "" handleShellAction(kind: kind, value: value) } } private func handleShellAction(kind: String, value: String) { switch kind { case "new_note": newNote() case "open": openNotePanel() case "save": saveNote() case "save_as": saveNoteAs() case "quit": NSApp.terminate(nil) case "settings": break case "export_crate": exportCrate() case "toggle_browser": DocumentBrowserController.shared?.toggle() case "set_theme_mode": ConfigManager.shared.themeMode = value NotificationCenter.default.post(name: .settingsChanged, object: nil) case "set_line_indicator": ConfigManager.shared.lineIndicatorMode = value NotificationCenter.default.post(name: .settingsChanged, object: nil) case "set_gutter_rainbow": ConfigManager.shared.gutterRainbow = (value == "true") NotificationCenter.default.post(name: .settingsChanged, object: nil) case "pick_auto_save_dir": pickAutoSaveDirectory() default: break } } private func persistViewportToNotesDir() { let text = viewport.getText() guard !AppState.isEffectivelyBlank(text) else { return } let hash = text.hashValue if hash == lastAutosavedHash { return } appState.writeAutosavedCopy(text: text) lastAutosavedHash = hash } // MARK: - Actions func newNote() { appState.newNote() viewport.setLang("") } func saveNote() { syncTextFromViewport() appState.bindAutoSaveURL() appState.saveNote() } func saveNoteAs() { syncTextFromViewport() let panel = NSSavePanel() panel.allowedContentTypes = AppDelegate.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) } } /// presents the file picker and forwards the chosen URL to the AppDelegate router. func openNotePanel() { let panel = NSOpenPanel() panel.allowedContentTypes = AppDelegate.supportedContentTypes panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } (NSApp.delegate as? AppDelegate)?.openInNewWindow(url) } } func printNote() { guard let handle = viewport.viewportHandle else { return } syncTextFromViewport() let title = appState.currentFileURL?.deletingPathExtension().lastPathComponent ?? "Acord Document" var len: UInt = 0 guard let ptr = title.withCString({ t in viewport_render_pdf(handle, t, &len) }), len > 0 else { let alert = NSAlert() alert.messageText = "Print failed" alert.informativeText = "Could not render this document to PDF." alert.runModal() return } let data = Data(bytes: ptr, count: Int(len)) viewport_free_bytes(ptr, len) let panel = NSSavePanel() panel.title = "Print to PDF" panel.prompt = "Save" panel.allowedContentTypes = [.pdf] panel.nameFieldStringValue = "\(title).pdf" panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } do { try data.write(to: url) NSWorkspace.shared.open(url) } catch { let alert = NSAlert() alert.messageText = "Print failed" alert.informativeText = error.localizedDescription alert.runModal() } } } func exportCrate() { syncTextFromViewport() guard let handle = viewport.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: window) { [weak self] 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() } } } } } func pickAutoSaveDirectory() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.canCreateDirectories = true panel.allowsMultipleSelection = false panel.directoryURL = URL(fileURLWithPath: ConfigManager.shared.autoSaveDirectory) panel.beginSheetModal(for: window) { response in guard response == .OK, let url = panel.url else { return } ConfigManager.shared.autoSaveDirectory = url.path NotificationCenter.default.post(name: .settingsChanged, object: nil) } } // MARK: - Helpers 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 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 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 = AppDelegate.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)" } // MARK: - Teardown func teardown() { autosaveTimer?.invalidate() autosaveTimer = nil for observer in observers { NotificationCenter.default.removeObserver(observer) } observers = [] titleCancellable = nil textCancellable = nil viewport.teardown() } }