Files
conjure/Sources/Conjure/NotesController.swift
T

446 lines
19 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 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 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),
])
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")
delete.contentTintColor = ThemePalette.error
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.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)
}
}