import AppKit import ConjureKit import UniformTypeIdentifiers import Highlighter @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: "(? [(range: NSRange, body: NSRange, language: String?)] { let source = text as NSString var blocks: [(NSRange, NSRange, String?)] = [] var cursor = 0 while let opening = openingFence.firstMatch(in: text, range: NSRange(location: cursor, length: source.length - cursor)) { let fence = source.substring(with: opening.range(at: 1)) let marker = NSRegularExpression.escapedPattern(for: String(fence.first!)) let closing = try! NSRegularExpression(pattern: "(?m)^ {0,3}\(marker){\(fence.count),}[ \\t]*\\r?$") let start = NSMaxRange(opening.range) let end = closing.firstMatch(in: text, range: NSRange(location: start, length: source.length - start)) let bodyEnd = end?.range.location ?? source.length cursor = end.map { NSMaxRange($0.range) } ?? source.length let language = source.substring(with: opening.range(at: 2)).split(whereSeparator: { $0.isWhitespace }).first.map(String.init) blocks.append((NSRange(location: opening.range.location, length: cursor - opening.range.location), NSRange(location: start, length: bodyEnd - start), language)) if cursor == source.length { break } } return blocks } static func apply(to editor: NSTextView) { guard let storage = editor.textStorage else { return } let text = editor.string let range = NSRange(location: 0, length: text.utf16.count) storage.beginEditing() defer { storage.endEditing() } storage.enumerateAttribute(.attachment, in: range) { value, _, _ in guard let attachment = value as? NSTextAttachment, let cell = attachment.attachmentCell as? NSTextAttachmentCell, let image = cell.image else { return } let size = image.size let scale = min(1, 460 / max(size.width, 1), 240 / max(size.height, 1)) if scale < 1 { image.size = NSSize(width: size.width * scale, height: size.height * scale) } } storage.addAttributes([.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.text], range: range) let blocks = codeBlocks(in: text) for (expression, attributes) in rules { for match in expression.matches(in: text, range: range) { guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue } storage.addAttributes(attributes, range: match.range) } } for block in blocks { storage.addAttributes([.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: SunsetPalette.secondaryText], range: block.range) storage.addAttribute(.foregroundColor, value: SunsetPalette.text, range: block.body) let code = (text as NSString).substring(with: block.body) guard let highlighted = highlighter.highlight(code, as: block.language), highlighted.string == code else { continue } highlighted.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: highlighted.length)) { color, range, _ in if let color { storage.addAttribute(.foregroundColor, value: color, range: NSRange(location: block.body.location + range.location, length: range.length)) } } } editor.typingAttributes = [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.text] } }