import AppKit import ConjureKit import UniformTypeIdentifiers @MainActor enum NotesAppearance { static let font = NSFont.monospacedSystemFont(ofSize: 15, weight: .regular) static let paragraph: NSParagraphStyle = { let style = NSMutableParagraphStyle() style.lineSpacing = 5 style.paragraphSpacing = 8 return style }() } @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 var themeObservation: ThemeObservation? private let noteTitle = NSTextField(labelWithString: "Untitled Note") private let vimButton = NSButton(title: "Vim Off", target: nil, action: nil) private let formatting = NSStackView() private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [ ("h", [.command, .shift]), ("b", .command), ("i", .command), ("s", [.command, .shift]), ("e", .command), ("k", [.command, .shift]), ("e", [.command, .shift]), ("b", [.command, .shift]), ("o", [.command, .shift]), ("l", [.command, .shift]), ("t", [.command, .shift]), ("u", [.command, .shift]), ] 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) actionsButton.target = self actionsButton.action = #selector(showActions) let controls = ActionPill(primaryButton: browseButton, actionsButton: actionsButton, primaryCommand: "p") noteTitle.font = .systemFont(ofSize: 13) noteTitle.textColor = ThemePalette.secondaryText noteTitle.alignment = .center noteTitle.lineBreakMode = .byTruncatingTail noteTitle.setAccessibilityLabel("Note title") let header = NSView() vimButton.target = self vimButton.action = #selector(toggleVim) vimButton.bezelStyle = .accessoryBarAction vimButton.font = .monospacedSystemFont(ofSize: 10, weight: .medium) vimButton.contentTintColor = ThemePalette.secondaryText vimButton.setAccessibilityLabel("Toggle Vim editing") vimButton.toolTip = "Toggle Vim editing. Escape: Normal mode, then close; i: Insert; v: Visual." editor.vimModeChanged = { [weak self] in self?.updateVimIndicator() } editor.vimEnabled = defaults.bool(forKey: "notesVimEnabled") for control in [noteTitle, vimButton] { control.translatesAutoresizingMaskIntoConstraints = false header.addSubview(control) control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true } NSLayoutConstraint.activate([ noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor), vimButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12), vimButton.widthAnchor.constraint(equalToConstant: 90), noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: header.leadingAnchor, constant: 44), noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: vimButton.leadingAnchor, constant: -8), ]) 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() } actions.deleteNote = { [weak self] in self?.deleteNote() } actions.copyNoteMarkdown = { [weak self] in self?.copyAsMarkdown() } actions.exportNoteMarkdown = { [weak self] in self?.exportAsMarkdown() } let tools = [ ("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"), ("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"), ("link", "Link"), ("curlybraces.square", "Code block"), ("text.quote", "Quote"), ("list.number", "Ordered list"), ("list.bullet", "Bullet list"), ("checklist", "Checklist"), ("underline", "Underline"), ] for (index, tool) in tools.enumerated() { let button = iconButton(tool.0, label: tool.1, action: #selector(formatNote(_:))) button.tag = index let shortcut = Self.formattingKeys[index] button.toolTip = "\(tool.1) (⌘\(shortcut.modifiers.contains(.shift) ? "⇧" : "")\(shortcut.key.uppercased()))" formatting.addArrangedSubview(button) } formatting.spacing = 8 let formattingScroll = NSScrollView() formattingScroll.drawsBackground = false formattingScroll.contentView.drawsBackground = false formattingScroll.hasHorizontalScroller = true formattingScroll.autohidesScrollers = true formattingScroll.scrollerStyle = .overlay formattingScroll.verticalScrollElasticity = .none formattingScroll.documentView = formatting formatting.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ formatting.widthAnchor.constraint(equalToConstant: CGFloat(tools.count * 28 + (tools.count - 1) * 8)), formatting.heightAnchor.constraint(equalToConstant: 28), formattingScroll.widthAnchor.constraint(greaterThanOrEqualToConstant: 28), formattingScroll.heightAnchor.constraint(equalToConstant: 36), ]) let footer = NSStackView(views: [formattingScroll, controls]) footer.alignment = .centerY footer.spacing = 8 editor.delegate = self editor.isRichText = true editor.importsGraphics = true editor.allowsUndo = true editor.drawsBackground = false editor.textColor = ThemePalette.text editor.insertionPointColor = ThemePalette.accent editor.selectedTextAttributes = [.backgroundColor: ThemePalette.selectionFill] editor.font = NotesAppearance.font editor.defaultParagraphStyle = NotesAppearance.paragraph 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: 22, height: 8) editor.setAccessibilityLabel("Note editor") let scroll = LauncherScrollView() scroll.edgeFadeHeight = 16 scroll.documentView = editor scroll.hasVerticalScroller = true scroll.autohidesScrollers = true scroll.drawsBackground = false scroll.scrollerStyle = .overlay scroll.automaticallyAdjustsContentInsets = false scroll.contentInsets = NSEdgeInsets(top: scroll.edgeFadeHeight, left: 0, bottom: scroll.edgeFadeHeight, right: 0) status.font = .systemFont(ofSize: 12) status.textColor = ThemePalette.secondaryText status.setAccessibilityLabel("Notes save status") status.lineBreakMode = .byTruncatingTail status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) status.isHidden = true let body = NSStackView(views: [scroll, status, footer]) body.orientation = .vertical body.alignment = .leading body.spacing = 0 for control in [header, body] { control.translatesAutoresizingMaskIntoConstraints = false view.addSubview(control) } NSLayoutConstraint.activate([ header.topAnchor.constraint(equalTo: view.topAnchor), header.leadingAnchor.constraint(equalTo: view.leadingAnchor), header.trailingAnchor.constraint(equalTo: view.trailingAnchor), header.heightAnchor.constraint(equalToConstant: 36), body.topAnchor.constraint(equalTo: header.bottomAnchor), body.leadingAnchor.constraint(equalTo: view.leadingAnchor), body.trailingAnchor.constraint(equalTo: view.trailingAnchor), body.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8), scroll.widthAnchor.constraint(equalTo: body.widthAnchor), footer.leadingAnchor.constraint(equalTo: body.leadingAnchor, constant: 24), footer.trailingAnchor.constraint(equalTo: body.trailingAnchor, constant: -8), status.leadingAnchor.constraint(equalTo: body.leadingAnchor, constant: 28), status.trailingAnchor.constraint(equalTo: body.trailingAnchor, constant: -12), scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 80), footer.heightAnchor.constraint(equalToConstant: 36), ]) themeObservation = ThemeObservation { [weak self] in guard let self else { return } self.noteTitle.textColor = ThemePalette.secondaryText self.vimButton.contentTintColor = ThemePalette.secondaryText self.status.textColor = ThemePalette.error for case let button as NSButton in self.formatting.arrangedSubviews { button.contentTintColor = ThemePalette.secondaryText } } guard let store else { editor.isEditable = false for control in [browseButton, actionsButton] { control.isEnabled = false } formatting.subviews.compactMap { $0 as? NSButton }.forEach { $0.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) } } @objc private func toggleVim() { editor.vimEnabled.toggle() defaults.set(editor.vimEnabled, forKey: "notesVimEnabled") focusEditor() } private func updateVimIndicator() { vimButton.title = editor.vimEnabled ? editor.vimMode.rawValue : "Vim Off" vimButton.setAccessibilityValue(editor.vimEnabled ? editor.vimMode.rawValue : "Off") vimButton.state = editor.vimEnabled ? .on : .off } private func configureIcon(_ button: NSButton, symbol: String, label: String) { button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? .withSymbolConfiguration(.init(pointSize: 16, weight: .regular)) button.imagePosition = .imageOnly button.bezelStyle = .accessoryBarAction button.showsBorderOnlyWhileMouseInside = true button.contentTintColor = ThemePalette.secondaryText button.toolTip = label button.setAccessibilityLabel(label) NSLayoutConstraint.activate([button.widthAnchor.constraint(equalToConstant: 28), button.heightAnchor.constraint(equalToConstant: 28)]) } private func iconButton(_ symbol: String, label: String, action: Selector) -> NSButton { let button = NSButton(title: "", target: self, action: action) configureIcon(button, symbol: symbol, label: label) return button } @objc private func formatNote(_ sender: NSButton) { editor.format(sender.tag) focusEditor() } func focusEditor() { view.window?.makeFirstResponder(editor) } func textDidChange(_ notification: Notification) { isDirty = true _ = save() } @discardableResult func save() -> Bool { guard isDirty, let store, let selectedID else { return true } do { try store.update(id: selectedID, contents: editor.documentContents) isDirty = false noteTitle.stringValue = store.notes.first { $0.id == selectedID }?.title ?? "Untitled Note" status.isHidden = true return true } catch { showError(error) return false } } func handle(_ selector: Selector) -> Bool { if actions.isShown { return actions.handle(selector) } if selector == #selector(NSResponder.cancelOperation(_:)), view.window?.firstResponder === editor { return editor.handleVimEscape() } return false } 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(_ event: NSEvent) -> Bool { guard store != nil, view.window?.attachedSheet == nil else { return false } let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift]) if modifiers.contains(.command) { editor.endInsertionSession(); editor.clearVimCommand() } let key = event.charactersIgnoringModifiers?.lowercased() ?? "" if let index = Self.formattingKeys.firstIndex(where: { $0.key == key && $0.modifiers == modifiers }) { guard editor.isEditable, !actions.isShown, view.window?.firstResponder === editor, let button = formatting.arrangedSubviews[index] as? NSButton else { return false } formatNote(button) return true } guard modifiers == .command 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 copyAsMarkdown() { actions.close(animated: false) do { let export = try NoteMarkdownExport(contents: editor.documentContents) let clipboard = NSPasteboard.general clipboard.clearContents() guard clipboard.setString(export.clipboardText, forType: .string) else { throw CocoaError(.fileWriteUnknown) } } catch { showExportError(error) } } private func exportAsMarkdown() { guard let window = view.window, window.attachedSheet == nil else { return } actions.close(animated: false) do { let export = try NoteMarkdownExport(contents: editor.documentContents) let panel = NSSavePanel() panel.title = "Export as Markdown" panel.prompt = "Export" panel.canCreateDirectories = true panel.nameFieldStringValue = export.hasImages ? "Note" : "Note.md" if export.hasImages { panel.message = "Create a new folder containing note.md and its images. Choose a folder name that does not already exist." } else { panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText] } panel.beginSheetModal(for: window) { [weak self] response in guard let self else { return } defer { self.focusEditor() } guard response == .OK, let url = panel.url else { return } do { try export.write(to: url) } catch { self.showExportError(error) } } } catch { showExportError(error) } } private func showExportError(_ error: Error) { status.isHidden = false status.stringValue = "Couldn’t export note: \(error.localizedDescription)" status.toolTip = status.stringValue status.textColor = ThemePalette.error } private func deleteNote() { guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return } actions.close(animated: false) let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Delete “\(noteTitle.stringValue)”?" alert.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone." alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}" let delete = alert.addButton(withTitle: "Delete Note") let theme = ThemeObservation { [weak delete] in delete?.contentTintColor = ThemePalette.error } delete.keyEquivalent = "\r" alert.window.initialFirstResponder = delete alert.window.defaultButtonCell = delete.cell as? NSButtonCell alert.beginSheetModal(for: window) { [weak self, theme] response in defer { withExtendedLifetime(theme) {} } guard let self else { return } defer { self.focusEditor() } guard response == .alertSecondButtonReturn, self.selectedID == id, self.save(), let store = self.store else { return } do { try store.delete(id: id) self.selectedID = nil self.defaults.removeObject(forKey: "selectedNote") self.editor.setDocumentContents(NSAttributedString(string: "")) self.editor.undoManager?.removeAllActions() self.editor.isEditable = false self.noteTitle.stringValue = "Untitled Note" self.select(try store.notes.first?.id ?? store.create()) } catch { self.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.setDocumentContents(note.contents) editor.undoManager?.removeAllActions() editor.isEditable = true editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) noteTitle.stringValue = note.title status.isHidden = true } private func showError(_ error: Error) { status.isHidden = false status.stringValue = "Couldn’t save, load, or delete notes: \(error.localizedDescription)" status.toolTip = status.stringValue status.textColor = ThemePalette.error } @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: ((NSEvent) -> Bool)? override func performKeyEquivalent(with event: NSEvent) -> Bool { if commandShortcut?(event) == true { return true } return super.performKeyEquivalent(with: event) } }