feat: add persistent Markdown notes

This commit is contained in:
2026-10-17 00:53:28 +02:00
parent 0f49209d95
commit e36a8bbc6b
13 changed files with 584 additions and 23 deletions
+6
View File
@@ -70,6 +70,9 @@ struct ConjureApp {
editItem.submenu = editMenu
menu.addItem(editItem)
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
let redo = editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "z")
redo.keyEquivalentModifierMask = [.command, .shift]
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
editMenu.addItem(.separator())
@@ -155,6 +158,9 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
startupRecoveryMessage = nil
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
launcher.saveNotes() ? .terminateNow : .terminateCancel
}
func applicationWillTerminate(_ notification: Notification) {
shortcuts.stop()
}
+46 -11
View File
@@ -35,7 +35,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var launcherFrameBeforeReview: NSRect?
private var translation: TranslationController?
private var emojiPicker: EmojiPickerController?
private var isCommandOpen: Bool { translation != nil || emojiPicker != nil }
private var notes: NotesController?
private let notesDirectory: URL?
private var isCommandOpen: Bool { activeCommand != nil }
private var activeCommand: Application.Command?
private var pendingPresentation: (() -> Void)?
private static let emojiIcon: NSImage = {
@@ -52,11 +54,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}()
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller(),
defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) {
defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general, notesDirectory: URL? = nil) {
self.history = history
self.uninstaller = uninstaller
self.defaults = defaults
self.pasteboard = pasteboard
self.notesDirectory = notesDirectory
let window = LauncherPanel(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: [.borderless],
@@ -85,6 +88,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
else { self.moveSelection(by: offset) }
}
window.commandNavigation = { [weak self] key in self?.emojiPicker?.handleControlKey(key) ?? false }
window.usesTextNavigation = { [weak self] in self?.notes != nil }
window.dismiss = { [weak self] in self?.dismiss() }
window.canUseNumberShortcuts = { [weak self] in
guard let self else { return false }
@@ -122,7 +126,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
uninstallReview?.goBack()
return
}
closeCommand()
guard closeCommand() else { return }
actions.close(animated: false)
(window as? LauncherPanel)?.beginNumberHintSession()
let frontmost = NSWorkspace.shared.frontmostApplication
@@ -140,7 +144,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
window?.makeFirstResponder(searchField)
if let notes { notes.focusEditor() }
else { window?.makeFirstResponder(searchField) }
(window as? LauncherPanel)?.sampleNumberHints()
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
refreshApplications()
@@ -164,7 +169,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
uninstallReview?.goBack()
return
}
closeCommand()
guard closeCommand() else { return }
actions.close(animated: false)
alignmentGuides?.end()
let previous = previousApplication
@@ -567,10 +572,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.locationLabel.isHidden = true
cell.kindLabel.attributedStringValue = history.isFavorite(entry)
? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind
cell.imageView?.image = entry.command == .emoji ? Self.emojiIcon
: NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil)
switch entry.command {
case .emoji:
cell.imageView?.image = Self.emojiIcon
cell.toolTip = "Search and copy emoji"
case .notes:
cell.imageView?.image = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil)
cell.toolTip = "Write notes with Markdown and images"
default:
cell.imageView?.image = NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil)
cell.toolTip = "Translate English to Japanese"
}
cell.imageView?.contentTintColor = entry.command == .emoji ? nil : SunsetPalette.accent
cell.toolTip = entry.command == .emoji ? "Search and copy emoji" : "Translate English to Japanese"
} else {
let application = results[row]
cell.textField?.stringValue = application.name
@@ -794,11 +807,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@objc private func goBackCommand() {
translation?.goBack()
emojiPicker?.goBack()
notes?.goBack()
}
private func closeCommand() {
func saveNotes() -> Bool { notes?.save() ?? true }
@discardableResult
private func closeCommand() -> Bool {
guard saveNotes() else { return false }
notes?.closed?()
translation?.goBack()
emojiPicker?.close()
return true
}
private func showCommand(_ command: Application.Command) {
@@ -825,6 +845,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
controller = tool
placeholder = "Search emoji by name or :shortcode:…"
accessibilityLabel = "Search emoji"
case .notes:
let tool = NotesController(defaults: defaults, directory: notesDirectory)
notes = tool
controller = tool
placeholder = "Conjure Notes"
accessibilityLabel = "Conjure Notes"
}
let previousQuery = searchField.stringValue
let previousPlaceholder = searchField.placeholderAttributedString
@@ -833,6 +859,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
controller.view.removeFromSuperview()
self.translation = nil
self.emojiPicker = nil
self.notes = nil
self.searchField.isEnabled = true
self.activeCommand = nil
self.commandBack.isHidden = true
self.commandBack.target = nil
@@ -851,6 +879,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
translation?.closed = close
emojiPicker?.closed = close
notes?.closed = close
actions.close(animated: false)
(window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = ""
@@ -864,6 +893,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
)
searchField.setAccessibilityLabel(accessibilityLabel)
if command == .notes {
searchField.stringValue = "Conjure Notes"
searchField.isEnabled = false
}
for view in launcherViews.dropFirst() {
view.isHidden = true
view.setAccessibilityHidden(true)
@@ -876,7 +909,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
controller.view.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 16),
controller.view.bottomAnchor.constraint(equalTo: content.bottomAnchor),
])
window?.makeFirstResponder(searchField)
if let notes { notes.focusEditor() }
else { window?.makeFirstResponder(searchField) }
}
}
@@ -1115,6 +1149,7 @@ final class LauncherPanel: NSPanel {
var numberHintsChanged: (() -> Void)?
var launchNumber: ((Int) -> Void)?
var commandNavigation: ((String) -> Bool)?
var usesTextNavigation: (() -> Bool)?
private var numberShortcuts = CommandNumberShortcuts()
private var commandHoldTask: Task<Void, Never>?
var numberHintsVisible: Bool { numberShortcuts.isVisible }
@@ -1218,7 +1253,7 @@ final class LauncherPanel: NSPanel {
}
resetNumberHints(modifierFlags: event.modifierFlags)
}
if event.type == .keyDown, attachedSheet == nil,
if event.type == .keyDown, attachedSheet == nil, usesTextNavigation?() != true,
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return }
switch event.charactersIgnoringModifiers?.lowercased() {
+236
View File
@@ -0,0 +1,236 @@
import AppKit
import ConjureKit
import UniformTypeIdentifiers
@MainActor
final class NotesController: NSViewController, NSTextViewDelegate {
private let notePicker = NSPopUpButton()
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() {
view = NSView()
notePicker.target = self
notePicker.action = #selector(switchNote)
notePicker.setAccessibilityLabel("Choose a note")
let create = NSButton(title: "New Note", target: self, action: #selector(newNote))
create.keyEquivalent = "n"
create.keyEquivalentModifierMask = .command
let image = NSButton(title: "Insert Image…", target: self, action: #selector(insertImage))
let header = NSStackView(views: [notePicker, create, image])
header.spacing = 8
notePicker.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
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
for control in [header, scroll, status] {
control.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(control)
}
NSLayoutConstraint.activate([
header.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
header.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
header.topAnchor.constraint(equalTo: view.topAnchor),
notePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 10),
scroll.leadingAnchor.constraint(equalTo: header.leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: header.trailingAnchor),
scroll.bottomAnchor.constraint(equalTo: status.topAnchor, constant: -8),
status.leadingAnchor.constraint(equalTo: header.leadingAnchor),
status.trailingAnchor.constraint(equalTo: header.trailingAnchor),
status.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -12),
])
guard let store else {
editor.isEditable = false
for control in [notePicker, create, image] { 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
updatePicker()
status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image"
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() {
guard let value = notePicker.selectedItem?.representedObject as? UUID else { return }
guard save() else { updatePicker(); 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)
updatePicker()
status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image"
status.textColor = SunsetPalette.secondaryText
}
private func updatePicker() {
notePicker.removeAllItems()
for note in store?.notes ?? [] {
notePicker.menu?.addItem(withTitle: note.title, action: nil, keyEquivalent: "")
notePicker.lastItem?.representedObject = note.id
if note.id == selectedID { notePicker.select(notePicker.lastItem) }
}
}
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 insertImage() {
guard let window = view.window, editor.isEditable else { return }
let panel = NSOpenPanel()
panel.allowedContentTypes = [.image]
panel.canChooseDirectories = false
panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, let self, let url = panel.url else { return }
do { try self.editor.insertImage(at: url); self.focusEditor() }
catch { self.showError(error) }
}
}
}
@MainActor
final class NotesTextView: NSTextView {
func insertImage(at url: URL) throws {
guard let image = NSImage(contentsOf: url) else {
throw CocoaError(.fileReadCorruptFile)
}
let attachment = NSTextAttachment()
attachment.attachmentCell = NSTextAttachmentCell(imageCell: image)
attachment.fileWrapper = try FileWrapper(url: url, options: .immediate)
let contents = NSAttributedString(attachment: attachment)
insertText(contents, replacementRange: selectedRange())
}
}
@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]+`|(?ms)^```[^\\n]*\\n.*?(?:^```[^\\n]*$|\\z)"), [.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: NSColor.systemGreen]),
]
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)
for (expression, attributes) in rules {
for match in expression.matches(in: text, range: range) {
storage.addAttributes(attributes, range: match.range)
}
}
editor.typingAttributes = [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: SunsetPalette.text]
}
}
+2
View File
@@ -4,6 +4,7 @@ public struct Application: Identifiable, Sendable {
public enum Command: String, CaseIterable, Sendable {
case translate
case emoji
case notes
}
public static let commands = Command.allCases.map { Application(command: $0) }
public let command: Command?
@@ -22,6 +23,7 @@ public struct Application: Identifiable, Sendable {
let name = switch command {
case .translate: "Conjure Translate"
case .emoji: "Conjure Emoji"
case .notes: "Conjure Notes"
}
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command)
}
+4
View File
@@ -120,6 +120,8 @@ public final class GlobalShortcuts {
case "c": return "Copy"
case "v": return "Paste"
case "a": return "Select All"
case "n": return "New Note"
case "z": return "Undo"
case "m": return "Minimize"
case "k": return "entry actions"
case "return", "enter": return "Open, Copy, Translate, or confirm uninstall"
@@ -128,6 +130,8 @@ public final class GlobalShortcuts {
}
} else if candidate.modifiers == [.command, .option], key == "h" {
return "Hide Others"
} else if candidate.modifiers == [.command, .shift], key == "z" {
return "Redo"
} else if candidate.modifiers == .control {
switch key {
case "h", "j", "k", "l": return "launcher and emoji navigation"
+74
View File
@@ -0,0 +1,74 @@
import AppKit
public struct Note: Identifiable {
public let id: UUID
public let contents: NSAttributedString
public let title: String
init(id: UUID, contents: NSAttributedString) {
self.id = id
self.contents = NSAttributedString(attributedString: contents)
var title = "Untitled Note"
contents.string.enumerateLines { line, stop in
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
title = String(trimmed.prefix(60))
stop = true
}
}
self.title = title
}
}
@MainActor
public final class NotesStore {
public private(set) var notes: [Note] = []
private let directory: URL
public init(directory: URL = FileManager.default.urls(
for: .applicationSupportDirectory, in: .userDomainMask
)[0].appendingPathComponent("Conjure/Notes", isDirectory: true)) throws {
self.directory = directory
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let files = try FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: nil
).filter { $0.pathExtension.lowercased() == "rtfd" }
.sorted { $0.lastPathComponent < $1.lastPathComponent }
notes = try files.map { url in
guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else {
throw CocoaError(.fileReadCorruptFile)
}
let contents = try NSAttributedString(
url: url, options: [.documentType: NSAttributedString.DocumentType.rtfd],
documentAttributes: nil
)
return Note(id: id, contents: contents)
}
}
public func create() throws -> UUID {
let note = Note(id: UUID(), contents: NSAttributedString(string: ""))
try save(note)
notes.append(note)
notes.sort { $0.id.uuidString < $1.id.uuidString }
return note.id
}
public func update(id: UUID, contents: NSAttributedString) throws {
guard let index = notes.firstIndex(where: { $0.id == id }) else {
throw CocoaError(.fileNoSuchFile)
}
let note = Note(id: id, contents: contents)
try save(note)
notes[index] = note
}
private func save(_ note: Note) throws {
let wrapper = try note.contents.fileWrapper(
from: NSRange(location: 0, length: note.contents.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
)
let url = directory.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true)
try wrapper.write(to: url, options: .atomic, originalContentsURL: url)
}
}