import AppKit import ConjureKit import UniformTypeIdentifiers @MainActor final class NotesController: NSViewController, NSTextViewDelegate { private let notePicker = NSPopUpButton() private let editor = NotesTextView() private let status = NSTextField(labelWithString: "") private let defaults: UserDefaults private var store: NotesStore? private var selectedID: UUID? private var isDirty = false private var loadError: Error? var closed: (() -> Void)? init(defaults: UserDefaults = .standard, directory: URL? = nil) { self.defaults = defaults super.init(nibName: nil, bundle: nil) do { store = try directory.map { try NotesStore(directory: $0) } ?? NotesStore() } catch { loadError = error } } required init?(coder: NSCoder) { fatalError("Conjure creates Notes programmatically") } override func loadView() { view = NSView() notePicker.target = self notePicker.action = #selector(switchNote) notePicker.setAccessibilityLabel("Choose a note") let create = NSButton(title: "New Note", target: self, action: #selector(newNote)) create.keyEquivalent = "n" create.keyEquivalentModifierMask = .command let image = NSButton(title: "Insert Image…", target: self, action: #selector(insertImage)) let header = NSStackView(views: [notePicker, create, image]) header.spacing = 8 notePicker.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) editor.delegate = self editor.isRichText = true editor.importsGraphics = true editor.allowsUndo = true editor.drawsBackground = false editor.textColor = SunsetPalette.text editor.insertionPointColor = SunsetPalette.accent editor.font = .systemFont(ofSize: 14) editor.isAutomaticQuoteSubstitutionEnabled = false editor.isAutomaticDashSubstitutionEnabled = false editor.isAutomaticTextReplacementEnabled = false editor.isVerticallyResizable = true editor.isHorizontallyResizable = false editor.autoresizingMask = [.width] editor.textContainer?.widthTracksTextView = true editor.textContainerInset = NSSize(width: 10, height: 10) editor.setAccessibilityLabel("Note editor") let scroll = NSScrollView() scroll.documentView = editor scroll.hasVerticalScroller = true scroll.autohidesScrollers = true scroll.drawsBackground = false scroll.wantsLayer = true scroll.layer?.cornerRadius = 8 scroll.layer?.borderWidth = 0.5 scroll.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor status.font = .systemFont(ofSize: 12) status.textColor = SunsetPalette.secondaryText status.setAccessibilityLabel("Notes save status") status.lineBreakMode = .byTruncatingTail for control in [header, scroll, status] { control.translatesAutoresizingMaskIntoConstraints = false view.addSubview(control) } NSLayoutConstraint.activate([ header.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), header.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), header.topAnchor.constraint(equalTo: view.topAnchor), notePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 10), scroll.leadingAnchor.constraint(equalTo: header.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: header.trailingAnchor), scroll.bottomAnchor.constraint(equalTo: status.topAnchor, constant: -8), status.leadingAnchor.constraint(equalTo: header.leadingAnchor), status.trailingAnchor.constraint(equalTo: header.trailingAnchor), status.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -12), ]) guard let store else { editor.isEditable = false for control in [notePicker, create, image] { control.isEnabled = false } showError(loadError!) return } do { let savedID = defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:)) let id = store.notes.first { $0.id == savedID }?.id ?? store.notes.first?.id select(try id ?? store.create()) } catch { editor.isEditable = false showError(error) } } func focusEditor() { view.window?.makeFirstResponder(editor) } func textDidChange(_ notification: Notification) { isDirty = true MarkdownHighlighting.apply(to: editor) _ = save() } @discardableResult func save() -> Bool { guard isDirty, let store, let selectedID else { return true } do { try store.update(id: selectedID, contents: editor.attributedString()) isDirty = false updatePicker() status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image" status.textColor = SunsetPalette.secondaryText return true } catch { showError(error) return false } } func goBack() { guard save() else { return } closed?() } @objc private func newNote() { guard save(), let store else { return } do { select(try store.create()); focusEditor() } catch { showError(error) } } @objc private func switchNote() { guard let value = notePicker.selectedItem?.representedObject as? UUID else { return } guard save() else { updatePicker(); return } select(value) focusEditor() } private func select(_ id: UUID) { guard let note = store?.notes.first(where: { $0.id == id }) else { return } selectedID = id defaults.set(id.uuidString, forKey: "selectedNote") editor.textStorage?.setAttributedString(note.contents) editor.undoManager?.removeAllActions() editor.isEditable = true editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) MarkdownHighlighting.apply(to: editor) updatePicker() status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image" status.textColor = SunsetPalette.secondaryText } private func updatePicker() { notePicker.removeAllItems() for note in store?.notes ?? [] { notePicker.menu?.addItem(withTitle: note.title, action: nil, keyEquivalent: "") notePicker.lastItem?.representedObject = note.id if note.id == selectedID { notePicker.select(notePicker.lastItem) } } } private func showError(_ error: Error) { status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)" status.toolTip = status.stringValue status.textColor = .systemRed } @objc private func insertImage() { guard let window = view.window, editor.isEditable else { return } let panel = NSOpenPanel() panel.allowedContentTypes = [.image] panel.canChooseDirectories = false panel.beginSheetModal(for: window) { [weak self] response in guard response == .OK, let self, let url = panel.url else { return } do { try self.editor.insertImage(at: url); self.focusEditor() } catch { self.showError(error) } } } } @MainActor final class NotesTextView: NSTextView { func insertImage(at url: URL) throws { guard let image = NSImage(contentsOf: url) else { throw CocoaError(.fileReadCorruptFile) } let attachment = NSTextAttachment() attachment.attachmentCell = NSTextAttachmentCell(imageCell: image) attachment.fileWrapper = try FileWrapper(url: url, options: .immediate) let contents = NSAttributedString(attachment: attachment) insertText(contents, replacementRange: selectedRange()) } } @MainActor enum MarkdownHighlighting { // ponytail: whole-note highlighting; incremental parsing if large notes make typing slow. private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [ (try! NSRegularExpression(pattern: "(?m)^#{1,6} .+$"), [.font: NSFont.systemFont(ofSize: 17, weight: .semibold), .foregroundColor: SunsetPalette.accent]), (try! NSRegularExpression(pattern: "\\*\\*[^\\n]+?\\*\\*|__[^\\n]+?__"), [.font: NSFont.boldSystemFont(ofSize: 14)]), (try! NSRegularExpression(pattern: "(?