import AppKit import ConjureKit import Highlighter @MainActor final class NotesController: NSViewController, NSTextViewDelegate { private let createButton = NSButton(title: "New Note", target: nil, action: nil) private let actionsButton = NSButton(title: "Actions", target: nil, action: nil) private let noteMenu = NSMenu(title: "Notes") private let editor = NSTextView() 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() createButton.target = self createButton.action = #selector(newNote) createButton.toolTip = "Create a note (Command-N)" actionsButton.target = self actionsButton.action = #selector(showActions) actionsButton.setAccessibilityLabel("Choose a note") actionsButton.toolTip = "Choose a note (Command-K)" actionsButton.menu = noteMenu let pill = ActionPill(primaryButton: createButton, actionsButton: actionsButton, primaryCommand: "n") let footer = NSStackView(views: [status, NSView(), pill]) footer.orientation = .horizontal footer.alignment = .centerY footer.spacing = 12 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 status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) for control in [scroll, footer] { control.translatesAutoresizingMaskIntoConstraints = false view.addSubview(control) } NSLayoutConstraint.activate([ scroll.topAnchor.constraint(equalTo: view.topAnchor), scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8), footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8), footer.heightAnchor.constraint(equalToConstant: 36), ]) guard let store else { editor.isEditable = false for control in [createButton, actionsButton] { 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 updateActions() status.stringValue = "Saved locally · Markdown · Paste or drop images" 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(_ sender: NSMenuItem) { guard let value = sender.representedObject as? UUID, save() else { 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) updateActions() status.stringValue = "Saved locally · Markdown · Paste or drop images" status.textColor = SunsetPalette.secondaryText } private func updateActions() { noteMenu.removeAllItems() for note in store?.notes ?? [] { let item = noteMenu.addItem(withTitle: note.title, action: #selector(switchNote(_:)), keyEquivalent: "") item.target = self item.representedObject = note.id item.state = note.id == selectedID ? .on : .off } } 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 showActions() { noteMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: actionsButton.bounds.maxY + 4), in: actionsButton) focusEditor() } } @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] } }