306 lines
14 KiB
Swift
306 lines
14 KiB
Swift
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 = 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() {
|
||
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)
|
||
_ = 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
|
||
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)
|
||
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")
|
||
|
||
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]
|
||
}
|
||
}
|