Files
conjure/Sources/Conjure/NotesController.swift
T

754 lines
40 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
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 let noteTitle = NSTextField(labelWithString: "Untitled Note")
private let formatting = NSStackView()
private static let formattingKeys: [(key: String, modifiers: NSEvent.ModifierFlags)] = [
("h", [.command, .shift]), ("b", .command), ("i", .command),
("x", [.command, .shift]), ("e", .command), ("k", [.command, .shift]),
("e", [.command, .shift]), ("b", [.command, .shift]),
("o", [.command, .shift]), ("u", [.command, .shift]), ("t", [.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 = SunsetPalette.secondaryText
noteTitle.alignment = .center
noteTitle.lineBreakMode = .byTruncatingTail
noteTitle.setAccessibilityLabel("Note title")
let header = NSView()
for control in [noteTitle] {
control.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(control)
control.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
}
NSLayoutConstraint.activate([
noteTitle.centerXAnchor.constraint(equalTo: header.centerXAnchor),
noteTitle.leadingAnchor.constraint(greaterThanOrEqualTo: header.leadingAnchor, constant: 44),
noteTitle.trailingAnchor.constraint(lessThanOrEqualTo: header.trailingAnchor, constant: -44),
])
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() }
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"),
]
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 = SunsetPalette.text
editor.insertionPointColor = SunsetPalette.accent
editor.selectedTextAttributes = [.backgroundColor: SunsetPalette.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 = SunsetPalette.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),
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),
])
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)
}
}
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 = SunsetPalette.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) {
guard editor.isEditable else { return }
let contents = editor.markdownContents
let source = contents.string as NSString
var selection = editor.markdownInsertionRange(for: editor.selectedRange())
if sender.tag == 6 {
selection = source.lineRange(for: selection)
if selection.length > 0, source.character(at: NSMaxRange(selection) - 1) == 10 { selection.length -= 1 }
}
let wrappers = [1: ("**", "**"), 2: ("*", "*"), 3: ("~~", "~~"), 4: ("`", "`"),
5: ("[", "](url)"), 6: ("```\n", "\n```")]
if let (open, close) = wrappers[sender.tag] {
let before = selection.location - open.utf16.count
let after = NSMaxRange(selection)
var stars = 0
if open == "*" {
while selection.location > stars, after + stars < source.length,
source.character(at: selection.location - stars - 1) == 42,
source.character(at: after + stars) == 42 { stars += 1 }
}
if before >= 0, after + close.utf16.count <= source.length,
source.substring(with: NSRange(location: before, length: open.utf16.count)) == open,
source.substring(with: NSRange(location: after, length: close.utf16.count)) == close,
open != "*" || stars % 2 == 1 {
let body = contents.attributedSubstring(from: selection)
selection = NSRange(location: before, length: selection.length + open.utf16.count + close.utf16.count)
replaceMarkdown(body, range: selection, selection: NSRange(location: before, length: body.length))
} else {
let body = NSMutableAttributedString(string: open, attributes: editor.typingAttributes)
body.append(contents.attributedSubstring(from: selection))
body.append(NSAttributedString(string: close, attributes: editor.typingAttributes))
replaceMarkdown(body, range: selection,
selection: NSRange(location: selection.location + open.utf16.count, length: selection.length))
}
} else {
let prefixes = [0: "# ", 7: "> ", 8: "1. ", 9: "- ", 10: "- [ ] "]
guard let prefix = prefixes[sender.tag] else { return }
let length = selection.length > 0 && source.character(at: NSMaxRange(selection) - 1) == 10
? selection.length - 1 : selection.length
let lines = source.lineRange(for: NSRange(location: selection.location, length: length))
let body = NSMutableAttributedString(attributedString: contents.attributedSubstring(from: lines))
let rows = body.string.components(separatedBy: "\n")
var offset = body.length, firstDelta = 0
for (index, line) in rows.enumerated().reversed() {
offset -= line.utf16.count + (index < rows.count - 1 ? 1 : 0)
if index == rows.count - 1, index > 0, line.isEmpty { continue }
let indent = String(line.prefix { $0 == " " || $0 == "\t" })
let content = String(line.dropFirst(indent.count))
let item = Self.listMatch(in: content as NSString)
let kind = item.map { $0.range(at: 6).location != NSNotFound ? 10 : $0.range(at: 3).location != NSNotFound ? 8 : 9 }
let removing = kind == sender.tag || (item == nil && content.hasPrefix(prefix))
let oldPrefix = item?.range.length ?? (removing ? prefix.utf16.count : 0)
let newPrefix = removing ? "" : sender.tag == 8 ? "\(index + 1). " : prefix
body.replaceCharacters(in: NSRange(location: offset + indent.utf16.count, length: oldPrefix),
with: NSAttributedString(string: newPrefix, attributes: editor.typingAttributes))
if index == 0 { firstDelta = newPrefix.utf16.count - oldPrefix }
}
let start = max(lines.location, selection.location + firstDelta)
let end = max(start, NSMaxRange(selection) + body.length - lines.length)
replaceMarkdown(body, range: lines, selection: NSRange(location: start, length: end - start))
}
focusEditor()
}
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) {
replaceMarkdown(NSAttributedString(string: replacement, attributes: editor.typingAttributes), range: range, selection: selection)
}
private func replaceMarkdown(_ replacement: NSAttributedString, 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
var selectedStart = max(start, selection.location + delta)
var selectedEnd = max(start, NSMaxRange(selection) + delta)
func isListLine(_ range: NSRange) -> Bool {
Self.listMatch(in: source.substring(with: range) as NSString) != nil
&& !codeBlocks.contains { NSIntersectionRange($0.range, range).length > 0 }
}
var block = source.lineRange(for: NSRange(location: start, length: 0))
while block.location > 0 {
let previous = source.lineRange(for: NSRange(location: block.location - 1, length: 0))
guard isListLine(previous) else { break }
block = NSUnionRange(previous, block)
}
while NSMaxRange(block) < source.length {
let next = source.lineRange(for: NSRange(location: NSMaxRange(block), length: 0))
guard isListLine(next) else { break }
block = NSUnionRange(block, next)
}
let body = NSMutableAttributedString(attributedString: editor.markdownContents.attributedSubstring(from: block))
body.replaceCharacters(in: NSRange(location: start - block.location, length: removed), with: replacement)
let changed = body.string as NSString
let oldDepth = Self.indentationWidth(indent)
let newDepth = Self.indentationWidth(replacement + (indent as NSString).substring(from: removed))
var ancestors: [(depth: Int, number: UInt64?, delimiter: String)] = []
var edits: [(range: NSRange, number: String)] = []
var oldLevel = true, newLevel = true
var offset = 0
while offset < changed.length {
let row = changed.lineRange(for: NSRange(location: offset, length: 0))
let text = changed.substring(with: row) as NSString
guard let match = Self.listMatch(in: text) else { break }
let depth = Self.indentationWidth(text.substring(with: match.range(at: 1)))
if offset + block.location > start {
if depth < oldDepth { oldLevel = false }
if depth < newDepth { newLevel = false }
}
while let last = ancestors.last, last.depth > depth { ancestors.removeLast() }
let peer = ancestors.last?.depth == depth ? ancestors.removeLast() : nil
var number: UInt64?
var delimiter = ""
if match.range(at: 3).location != NSNotFound {
number = UInt64(text.substring(with: match.range(at: 3)))
delimiter = text.substring(with: match.range(at: 4))
if offset + block.location >= start,
(oldLevel && depth == oldDepth) || (newLevel && depth == newDepth) {
if let peer, peer.delimiter == delimiter, let previous = peer.number, previous < UInt64.max {
number = previous + 1
} else if !ancestors.isEmpty || offset + block.location > start {
number = 1
}
if let number, UInt64(text.substring(with: match.range(at: 3))) != number {
let digits = match.range(at: 3)
edits.append((NSRange(location: offset + digits.location, length: digits.length), String(number)))
}
}
}
ancestors.append((depth, number, delimiter))
offset = NSMaxRange(row)
}
for edit in edits.reversed() {
body.replaceCharacters(in: edit.range, with: edit.number)
let location = block.location + edit.range.location
let difference = edit.number.utf16.count - edit.range.length
func adjusted(_ position: Int) -> Int {
if position <= location { return position }
if position < location + edit.range.length { return location + min(position - location, edit.number.utf16.count) }
return position + difference
}
selectedStart = adjusted(selectedStart)
selectedEnd = adjusted(selectedEnd)
}
replaceMarkdown(body, range: block,
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
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 {
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(_ event: NSEvent) -> Bool {
guard store != nil, view.window?.attachedSheet == nil else { return false }
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
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 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")
delete.contentTintColor = .systemRed
delete.keyEquivalent = "\r"
alert.window.initialFirstResponder = delete
alert.window.defaultButtonCell = delete.cell as? NSButtonCell
alert.beginSheetModal(for: window) { [weak self] response in
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.setMarkdownContents(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.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()
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 = .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: ((NSEvent) -> Bool)?
override func performKeyEquivalent(with event: NSEvent) -> Bool {
if commandShortcut?(event) == true {
return true
}
return super.performKeyEquivalent(with: event)
}
}
@MainActor
enum MarkdownHighlighting {
// ponytail: whole-note highlighting; incremental parsing if large notes make typing slow.
static let hiddenMarkup = NSAttributedString.Key("Conjure.hiddenMarkup")
private static let inlineCode = try! NSRegularExpression(pattern: "(?<![\\\\`])(?<marker>`+)(?<body>[^`\\n]+)\\k<marker>(?!`)")
private static let rules: [(NSRegularExpression, [NSAttributedString.Key: Any])] = [
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>\\*{2,3}|_{2,3})(?<body>\\S(?:[^\\n]*?\\S)?)\\k<marker>(?![*_])"), [.font: NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize, weight: .bold)]),
(try! NSRegularExpression(pattern: "(?<![\\\\*_])(?<marker>[*_])(?<body>[^\\s*_](?:[^*_\\n]*?[^\\s*_])?)\\k<marker>(?![*_])"), [.font: NSFontManager.shared.convert(NotesAppearance.font, toHaveTrait: .italicFontMask)]),
(try! NSRegularExpression(pattern: "(?<!\\\\)!?\\[(?<body>[^\\]\\n]+)\\]\\([^\\n)]+\\)|(?m)^\\s*(?:[-+*]|[0-9]+\\.)\\s"), [.foregroundColor: SunsetPalette.accent]),
(try! NSRegularExpression(pattern: "(?<!\\\\)~~(?<body>\\S(?:[^\\n]*?\\S)?)~~"), [.strikethroughStyle: NSUnderlineStyle.single.rawValue]),
(try! NSRegularExpression(pattern: "(?m)^(?:>[ \\t]*)+(?<body>[^\\n]+)"), [:]),
]
private static let heading = try! NSRegularExpression(pattern: "(?m)^(#{1,6}) (?<body>.+)$")
private static let headingFonts = [11.0, 8, 6, 4, 2, 0].map {
NSFont.monospacedSystemFont(ofSize: NotesAppearance.font.pointSize + $0, weight: .semibold)
}
private static let highlighter: Highlighter = {
guard let value = Highlighter() else { preconditionFailure("Missing bundled syntax highlighter") }
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: NotesAppearance.font.pointSize)
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.removeAttribute(.strikethroughStyle, range: range)
storage.addAttribute(hiddenMarkup, value: false, range: range)
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
.paragraphStyle: NotesAppearance.paragraph], range: range)
let blocks = codeBlocks(in: text)
func hideSyntax(_ match: NSTextCheckingResult) {
let body = match.range(withName: "body")
guard body.location != NSNotFound else { return }
if body.location > match.range.location {
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: match.range.location, length: body.location - match.range.location))
}
if NSMaxRange(body) < NSMaxRange(match.range) {
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: NSMaxRange(body), length: NSMaxRange(match.range) - NSMaxRange(body)))
}
}
let codeSpans = inlineCode.matches(in: text, range: range).filter { match in
!blocks.contains { NSIntersectionRange($0.range, match.range).length > 0 }
}
for match in codeSpans {
storage.addAttributes([.font: NotesAppearance.font, .foregroundColor: NSColor.systemGreen], range: match.range)
hideSyntax(match)
}
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 }),
!codeSpans.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
storage.addAttributes(attributes, range: match.range)
if match.range(withName: "marker").length == 3,
let font = attributes[.font] as? NSFont {
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask),
range: match.range)
}
hideSyntax(match)
}
}
for match in heading.matches(in: text, range: range) {
guard !blocks.contains(where: { NSIntersectionRange($0.range, match.range).length > 0 }) else { continue }
hideSyntax(match)
let font = headingFonts[match.range(at: 1).length - 1]
storage.enumerateAttribute(.font, in: match.range) { value, run, _ in
let traits = (value as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
storage.addAttribute(.font, value: NSFontManager.shared.convert(font, toHaveTrait: traits), range: run)
}
}
for block in blocks {
let source = text as NSString
let opening = source.lineRange(for: NSRange(location: block.range.location, length: 0))
let openingText = source.substring(with: opening).trimmingCharacters(in: .newlines)
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: opening.location, length: openingText.utf16.count))
if NSMaxRange(block.body) < NSMaxRange(block.range) {
storage.addAttribute(hiddenMarkup, value: true,
range: NSRange(location: NSMaxRange(block.body), length: NSMaxRange(block.range) - NSMaxRange(block.body)))
}
storage.addAttributes([.font: NotesAppearance.font,
.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: NotesAppearance.font, .foregroundColor: SunsetPalette.text,
.paragraphStyle: NotesAppearance.paragraph]
}
}