import AppKit import ConjureKit import Highlighter @MainActor final class NotesController: NSViewController, NSTextViewDelegate { private let browseButton = NSButton(title: "Browse Notes", target: nil, action: nil) private let actionsButton = NSButton(title: "Actions", target: nil, action: nil) private let actions = ApplicationActionsController() 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() { let content = NotesView() content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false } view = content browseButton.target = self browseButton.action = #selector(browseNotes) browseButton.setAccessibilityLabel("Browse notes") browseButton.toolTip = "Browse notes (Command-P)" actionsButton.target = self actionsButton.action = #selector(showActions) actionsButton.setAccessibilityLabel("Notes actions") actionsButton.toolTip = "Notes actions (Command-K)" actions.closed = { [weak self] in self?.focusEditor() } actions.selectNote = { [weak self] in self?.switchNote($0) } actions.newNote = { [weak self] in self?.newNote() } actions.browseNotes = { [weak self] in self?.browseNotes() } let pill = ActionPill(primaryButton: browseButton, actionsButton: actionsButton, primaryCommand: "p") 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 [browseButton, 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) editor.refreshTasks() _ = save() } private static let listItem = try! NSRegularExpression( pattern: "^([ \\t]*)([-+*]|([0-9]+)([.)]))([ \\t]+|$)(?:\\[([ xX])\\]([ \\t]+|$))?" ) private static func indentationWidth(_ indentation: String) -> Int { indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 } } private static func continuedPrefix(_ item: NSTextCheckingResult, in line: NSString) -> String? { var marker = line.substring(with: item.range(at: 2)) if item.range(at: 3).location != NSNotFound { guard let number = UInt64(line.substring(with: item.range(at: 3))), number < UInt64.max else { return nil } marker = "\(number + 1)" + line.substring(with: item.range(at: 4)) } var prefix = line.substring(with: item.range(at: 1)) + marker + line.substring(with: item.range(at: 5)) if item.range(at: 6).location != NSNotFound { prefix += "[ ]" + line.substring(with: item.range(at: 7)) } return prefix } func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { guard textView === editor, editor.isEditable, commandSelector == #selector(NSResponder.insertNewline(_:)) else { return false } let source = editor.string as NSString let selection = editor.selectedRange() var start = 0, end = 0 source.getLineStart(&start, end: nil, contentsEnd: &end, for: NSRange(location: selection.location, length: 0)) let line = source.substring(with: NSRange(location: start, length: end - start)) as NSString guard let item = Self.listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)), selection.location >= start + item.range.length, NSMaxRange(selection) <= end, !MarkdownHighlighting.codeBlocks(in: editor.string).contains(where: { selection.location >= $0.body.location && selection.location <= NSMaxRange($0.body) }) else { return false } let indent = line.substring(with: item.range(at: 1)) let marker = line.substring(with: item.range(at: 2)) // Spaced thematic breaks are not list items. if marker == "-" || marker == "*" { let characters = (line as String).lazy.filter { !$0.isWhitespace } let bullet = marker.first! if characters.count >= 3, characters.allSatisfy({ $0 == bullet }) { return false } } let body = line.substring(from: item.range.length) if body.trimmingCharacters(in: .whitespaces).isEmpty { var replacement = "" var previous = start let depth = Self.indentationWidth(indent) while depth > 0, previous > 0 { let range = source.lineRange(for: NSRange(location: previous - 1, length: 0)) previous = range.location let ancestor = source.substring(with: range) as NSString if let parent = Self.listItem.firstMatch(in: ancestor as String, range: NSRange(location: 0, length: ancestor.length)) { let parentIndent = ancestor.substring(with: parent.range(at: 1)) if Self.indentationWidth(parentIndent) < depth { replacement = Self.continuedPrefix(parent, in: ancestor) ?? "" break } } else if !(ancestor as String).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let leading = (ancestor as String).prefix { $0 == " " || $0 == "\t" } if Self.indentationWidth(String(leading)) < depth { break } } } editor.insertText(replacement, replacementRange: NSRange(location: start, length: end - start)) } else { guard let prefix = Self.continuedPrefix(item, in: line) else { return false } editor.insertText("\n" + prefix, replacementRange: selection) } return true } @discardableResult func save() -> Bool { guard isDirty, let store, let selectedID else { return true } do { try store.update(id: selectedID, contents: editor.attributedString()) isDirty = false status.stringValue = "Saved locally · Markdown · Paste or drop images" status.textColor = SunsetPalette.secondaryText return true } catch { showError(error) return false } } func handle(_ selector: Selector) -> Bool { actions.isShown && actions.handle(selector) } func handleControlKey(_ key: String) -> Bool { guard actions.isShown, key == "j" || key == "k" else { return false } actions.moveSelection(by: key == "j" ? 1 : -1) return true } func handleCommandKey(_ key: String) -> Bool { guard store != nil else { return false } switch key { case "p": browseNotes() case "k": showActions() case "n": newNote() default: return false } return true } @discardableResult func close() -> Bool { guard save() else { return false } actions.close(animated: false) closed?() return true } func goBack() { if actions.isShown { actions.close() } else { _ = close() } } @objc private func newNote() { actions.close(animated: false) guard save(), let store else { return } do { select(try store.create()); focusEditor() } catch { showError(error) } } private func switchNote(_ id: UUID) { guard save() else { return } select(id) 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) editor.refreshTasks() status.stringValue = "Saved locally · Markdown · Paste or drop images" status.textColor = SunsetPalette.secondaryText } private func showError(_ error: Error) { status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)" status.toolTip = status.stringValue status.textColor = .systemRed } @objc func browseNotes() { guard let store else { return } actions.showNotes(store.notes.map { (id: $0.id, title: $0.title) }, selected: selectedID, relativeTo: browseButton) } @objc func showActions() { guard store != nil else { return } actions.showNotesActions(relativeTo: actionsButton) } } @MainActor private final class NotesView: NSView { var commandShortcut: ((String) -> Bool)? override func performKeyEquivalent(with event: NSEvent) -> Bool { if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command, commandShortcut?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return true } return super.performKeyEquivalent(with: event) } } @MainActor final class NotesTextView: NSTextView { private struct Task { let marker: NSRange let state: NSRange let checked: Bool let title: String } private static let taskPattern = try! NSRegularExpression( pattern: "(?m)^[ \\t]*((?:[-+*]|[0-9]+[.)])[ \\t]+\\[([ xX])\\])(?=[ \\t]|\\r?$)" ) private var tasks: [Task] = [] private var checkboxes: [NSButton] = [] // ponytail: scan the whole note on edits; incremental parsing if large task lists make typing slow. func refreshTasks() { guard let layoutManager else { return } let source = string as NSString let range = NSRange(location: 0, length: source.length) layoutManager.removeTemporaryAttribute(.foregroundColor, forCharacterRange: range) let code = MarkdownHighlighting.codeBlocks(in: string) tasks = Self.taskPattern.matches(in: string, range: range).compactMap { match in guard !code.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { return nil } let end = NSMaxRange(source.lineRange(for: match.range)) let title = source.substring(with: NSRange(location: NSMaxRange(match.range), length: end - NSMaxRange(match.range))) .trimmingCharacters(in: .whitespacesAndNewlines) return Task(marker: match.range(at: 1), state: match.range(at: 2), checked: source.substring(with: match.range(at: 2)) != " ", title: title) } while checkboxes.count > tasks.count { let checkbox = checkboxes.removeLast() if window?.firstResponder === checkbox { window?.makeFirstResponder(self) } checkbox.removeFromSuperview() } while checkboxes.count < tasks.count { let checkbox = NSButton(checkboxWithTitle: "", target: self, action: #selector(toggleTask(_:))) checkbox.controlSize = .small checkbox.contentTintColor = SunsetPalette.accent checkbox.sizeToFit() checkboxes.append(checkbox) addSubview(checkbox) } for (index, task) in tasks.enumerated() { let checkbox = checkboxes[index] checkbox.tag = index checkbox.state = task.checked ? .on : .off checkbox.isEnabled = isEditable checkbox.setAccessibilityLabel(task.title.isEmpty ? "Task" : task.title) checkbox.toolTip = task.title layoutManager.addTemporaryAttribute(.foregroundColor, value: NSColor.clear, forCharacterRange: task.marker) } positionCheckboxes() } override func layout() { super.layout() positionCheckboxes() } private func positionCheckboxes() { guard let layoutManager, let textContainer else { return } layoutManager.ensureLayout(for: textContainer) for (index, task) in tasks.enumerated() { let glyphs = layoutManager.glyphRange(forCharacterRange: task.marker, actualCharacterRange: nil) let rect = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer) .offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y) let checkbox = checkboxes[index] checkbox.setFrameOrigin(NSPoint(x: rect.minX, y: rect.midY - checkbox.frame.height / 2)) } } @objc private func toggleTask(_ sender: NSButton) { guard isEditable, tasks.indices.contains(sender.tag) else { return } let task = tasks[sender.tag] let selection = selectedRange() breakUndoCoalescing() insertText(task.checked ? " " : "x", replacementRange: task.state) breakUndoCoalescing() setSelectedRange(selection) } } @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] } }