Files
conjure/Sources/Conjure/NotesController.swift
T
2026-10-19 15:11:47 +02:00

429 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 listMatch(in line: NSString) -> NSTextCheckingResult? {
guard let item = listItem.firstMatch(in: line as String, range: NSRange(location: 0, length: line.length)) else { return nil }
let marker = line.substring(with: item.range(at: 2))
// Spaced thematic breaks are not list items or list ancestors.
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 nil }
}
return item
}
private static func indentationWidth(_ indentation: String) -> Int {
indentation.reduce(0) { $1 == "\t" ? $0 + 4 - $0 % 4 : $0 + 1 }
}
private static func outdentLength(_ indentation: String) -> Int {
indentation.hasPrefix("\t") ? 1 : indentation.prefix(2).prefix { $0 == " " }.count
}
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
}
private func setSelectionWithUndo(_ selection: NSRange) {
let previous = editor.selectedRange()
editor.undoManager?.registerUndo(withTarget: self) { $0.setSelectionWithUndo(previous) }
editor.setSelectedRange(selection)
}
private func replaceListMarkdown(_ replacement: String, range: NSRange, selection: NSRange) {
editor.undoManager?.beginUndoGrouping()
// Native text undo restores its diff boundary, not the user's selection.
setSelectionWithUndo(editor.selectedRange())
editor.replaceMarkdown(replacement, range: range)
setSelectionWithUndo(editor.renderedRange(for: selection))
editor.undoManager?.endUndoGrouping()
}
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
let newline = commandSelector == #selector(NSResponder.insertNewline(_:))
let indenting = commandSelector == #selector(NSResponder.insertTab(_:))
let outdenting = commandSelector == #selector(NSResponder.insertBacktab(_:))
guard textView === editor, editor.isEditable, newline || indenting || outdenting else { return false }
let source = editor.markdownString as NSString
let selection = editor.markdownInsertionRange(for: 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.listMatch(in: line), NSMaxRange(selection) <= end else { return false }
let codeBlocks = MarkdownHighlighting.codeBlocks(in: source as String)
guard !codeBlocks.contains(where: {
NSIntersectionRange($0.range, NSRange(location: start, length: end - start)).length > 0
}) else { return false }
let indent = line.substring(with: item.range(at: 1))
if indenting {
let marker = editor.renderedRange(for:
NSRange(location: start + item.range(at: 2).location, length: 0)).location
// A literal marker still needs native Tab to complete its separator.
guard editor.textStorage?.attribute(.attachment, at: marker, effectiveRange: nil) is NSTextAttachment else { return false }
}
if indenting || outdenting {
let removed = outdenting ? Self.outdentLength(indent) : 0
guard indenting || removed > 0 else { return true }
let replacement = indenting ? " " : ""
let delta = replacement.utf16.count - removed
let selectedStart = max(start, selection.location + delta)
let selectedEnd = max(start, NSMaxRange(selection) + delta)
replaceListMarkdown(replacement, range: NSRange(location: start, length: removed),
selection: NSRange(location: selectedStart, length: selectedEnd - selectedStart))
return true
}
let markerEnd = item.range(at: 6).location == NSNotFound
? NSMaxRange(item.range(at: 2)) : NSMaxRange(item.range(at: 6)) + 1
guard selection.location >= start + markerEnd else { return false }
let body = line.substring(from: item.range.length)
if body.trimmingCharacters(in: .whitespaces).isEmpty {
var replacement = indent.isEmpty ? "" : line.substring(from: Self.outdentLength(indent))
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.listMatch(in: ancestor),
!codeBlocks.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) {
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 }
}
}
replaceListMarkdown(replacement, range: NSRange(location: start, length: end - start),
selection: NSRange(location: start + replacement.utf16.count, length: 0))
} else {
guard let prefix = Self.continuedPrefix(item, in: line) else { return false }
replaceListMarkdown("\n" + prefix, range: selection,
selection: NSRange(location: selection.location + 1 + prefix.utf16.count, length: 0))
}
return true
}
@discardableResult
func save() -> Bool {
guard isDirty, let store, let selectedID else { return true }
do {
try store.update(id: selectedID, contents: editor.markdownContents)
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.setMarkdownContents(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
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: "(?<!\\*)\\*(?!\\*)[^*\\n]+\\*(?!\\*)|(?<!_)_(?!_)[^_\\n]+_(?!_)"), [.font: NSFontManager.shared.convert(.systemFont(ofSize: 14), toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "!?\\[[^\\]\\n]+\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "`[^`\\n]+`"), [.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: NSColor.systemGreen]),
]
private static let highlighter: Highlighter = {
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 14)
return value
}()
private static let openingFence = try! NSRegularExpression(pattern: "(?m)^ {0,3}(`{3,}|~{3,})([^\\r\\n]*)\\r?\\n")
static func codeBlocks(in text: String) -> [(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]
}
}