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

262 lines
12 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 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: "(?<!\\*)\\*(?!\\*)[^*\\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")
private 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]
}
}