Compare commits

...

2 Commits

Author SHA1 Message Date
hl-valdemar 433c5c3c2a fix: highlight fenced code by language 2026-10-17 01:16:15 +02:00
hl-valdemar e36a8bbc6b feat: add persistent Markdown notes 2026-10-17 01:16:15 +02:00
17 changed files with 681 additions and 25 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ build:
mkdir -p $(APP)/Contents/MacOS $(APP)/Contents/Resources mkdir -p $(APP)/Contents/MacOS $(APP)/Contents/Resources
cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure
cp Resources/Info.plist $(APP)/Contents/Info.plist cp Resources/Info.plist $(APP)/Contents/Info.plist
cp -R .build/release/Conjure_ConjureKit.bundle $(APP)/Contents/Resources/ cp -R .build/release/*.bundle $(APP)/Contents/Resources/
swift Scripts/icon.swift .build/Conjure.iconset swift Scripts/icon.swift .build/Conjure.iconset
iconutil -c icns .build/Conjure.iconset -o $(APP)/Contents/Resources/Conjure.icns iconutil -c icns .build/Conjure.iconset -o $(APP)/Contents/Resources/Conjure.icns
codesign --force --sign "$(CODE_SIGN_IDENTITY)" $(APP) codesign --force --sign "$(CODE_SIGN_IDENTITY)" $(APP)
+15
View File
@@ -0,0 +1,15 @@
{
"originHash" : "fc870f54e8ae0c2fe51ba1e87667691472a20f609075b305c4117df7d7db9336",
"pins" : [
{
"identity" : "highlighterswift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/smittytone/HighlighterSwift",
"state" : {
"revision" : "fe7aae9c9b31d3b296fd3d2dd575e1a207bb29e0",
"version" : "3.1.0"
}
}
],
"version" : 3
}
+4 -1
View File
@@ -5,9 +5,12 @@ let package = Package(
name: "Conjure", name: "Conjure",
platforms: [.macOS(.v14)], platforms: [.macOS(.v14)],
products: [.executable(name: "Conjure", targets: ["Conjure"])], products: [.executable(name: "Conjure", targets: ["Conjure"])],
dependencies: [.package(url: "https://github.com/smittytone/HighlighterSwift", from: "3.1.0")],
targets: [ targets: [
.target(name: "ConjureKit", resources: [.process("Resources")]), .target(name: "ConjureKit", resources: [.process("Resources")]),
.executableTarget(name: "Conjure", dependencies: ["ConjureKit"]), .executableTarget(name: "Conjure", dependencies: [
"ConjureKit", .product(name: "Highlighter", package: "HighlighterSwift"),
]),
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]), .testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]),
] ]
) )
+13 -3
View File
@@ -1,6 +1,6 @@
# Conjure # Conjure
A native macOS launcher for applications, calculations, unit and currency conversions, emoji, and English → Japanese translation. A native macOS launcher for applications, calculations, unit and currency conversions, emoji, notes, and English → Japanese translation.
## Build and install ## Build and install
@@ -44,7 +44,7 @@ Conjure searches the system and user Applications folders. Hover over a result t
Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut. Conjure runs without a Dock icon. Dismissing the launcher leaves it running; reopen it from Finder or use your configured global shortcut.
The launcher, emoji picker, and translation tool open immediately without window animations or content fades, including from global shortcuts. Shortcut destinations are prepared before the window appears. Settings is constructed only when needed, not before the launcher appears. The launcher, emoji picker, notes, and translation tool open immediately without window animations or content fades, including from global shortcuts. Shortcut destinations are prepared before the window appears. Settings is constructed only when needed, not before the launcher appears.
### Controls ### Controls
@@ -65,7 +65,7 @@ The Actions surface crops the launcher’s full-size background at its own posit
### Global shortcuts and launch at login ### Global shortcuts and launch at login
In Settings, choose a **Global shortcut destination**: **Applications**, **Conjure Emoji**, or **Conjure Translate**. Choose **Record Shortcut…** and press a combination containing Command, Option, or Control. For example, select **Conjure Emoji** and record Command-Shift-Space to open the emoji picker directly. **Clear** disables only the selected destination's shortcut. No shortcuts are assigned by default; existing launcher bindings are preserved. In Settings, choose a **Global shortcut destination**: **Applications**, **Conjure Emoji**, **Conjure Notes**, or **Conjure Translate**. Choose **Record Shortcut…** and press a combination containing Command, Option, or Control. For example, select **Conjure Emoji** and record Command-Shift-Space to open the emoji picker directly. **Clear** disables only the selected destination's shortcut. No shortcuts are assigned by default; existing launcher bindings are preserved.
The Applications shortcut always targets the application launcher, not the last-used tool. Command shortcuts open their selected tool directly. Pressing a shortcut while its destination is already frontmost dismisses Conjure; switching destinations opens a fresh view. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation. The Applications shortcut always targets the application launcher, not the last-used tool. Command shortcuts open their selected tool directly. Pressing a shortcut while its destination is already frontmost dismisses Conjure; switching destinations opens a fresh view. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation.
@@ -116,6 +116,16 @@ With Actions open, the Back chevron closes Actions first; another Back returns t
The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application. The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application.
## Notes
Search for **Conjure Notes**, or assign it a global shortcut in Settings. **New Note** (Command-N) creates a note; the menu switches between notes, and reopening Notes restores the last selection. Each note’s first nonempty line becomes its menu title.
Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible. Paste images or use **Insert Image…** to embed a local image. Command-Z undoes edits; Command-Shift-Z redoes them. Undo history is cleared when switching notes so edits cannot spill into another note.
Fenced code uses [HighlighterSwift](https://github.com/smittytone/HighlighterSwift) (Highlight.js) for language-aware token colors. Add a language after the opening backticks or tildes, for example `py`, `python`, `swift`, `js`, or `json`. Unlabeled fences use automatic detection; unknown languages remain plain monospace. Highlighting runs entirely offline.
Notes autosave locally as RTFD packages, including embedded images, under `~/Library/Application Support/Conjure/Notes`. No account, network access, or cloud sync is involved. Save failures appear below the editor and prevent switching, closing, or quitting until saving succeeds, preserving the unsaved buffer. The Back chevron restores the launcher search; Escape or Command-W dismisses Conjure.
## Translation ## Translation
Search for **Conjure Translate** and open it. Enter English in the search bar, choose a provider, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input returns to the launcher and restores your search; Escape dismisses Conjure. Search for **Conjure Translate** and open it. Enter English in the search bar, choose a provider, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input returns to the launcher and restores your search; Escape dismisses Conjure.
+6
View File
@@ -70,6 +70,9 @@ struct ConjureApp {
editItem.submenu = editMenu editItem.submenu = editMenu
menu.addItem(editItem) menu.addItem(editItem)
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") 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: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
editMenu.addItem(.separator()) editMenu.addItem(.separator())
@@ -155,6 +158,9 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate {
startupRecoveryMessage = nil startupRecoveryMessage = nil
} }
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
launcher.saveNotes() ? .terminateNow : .terminateCancel
}
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
shortcuts.stop() shortcuts.stop()
} }
+46 -11
View File
@@ -35,7 +35,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var launcherFrameBeforeReview: NSRect? private var launcherFrameBeforeReview: NSRect?
private var translation: TranslationController? private var translation: TranslationController?
private var emojiPicker: EmojiPickerController? 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 activeCommand: Application.Command?
private var pendingPresentation: (() -> Void)? private var pendingPresentation: (() -> Void)?
private static let emojiIcon: NSImage = { private static let emojiIcon: NSImage = {
@@ -52,11 +54,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}() }()
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller(), 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.history = history
self.uninstaller = uninstaller self.uninstaller = uninstaller
self.defaults = defaults self.defaults = defaults
self.pasteboard = pasteboard self.pasteboard = pasteboard
self.notesDirectory = notesDirectory
let window = LauncherPanel( let window = LauncherPanel(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474), contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: [.borderless], styleMask: [.borderless],
@@ -85,6 +88,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
else { self.moveSelection(by: offset) } else { self.moveSelection(by: offset) }
} }
window.commandNavigation = { [weak self] key in self?.emojiPicker?.handleControlKey(key) ?? false } 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.dismiss = { [weak self] in self?.dismiss() }
window.canUseNumberShortcuts = { [weak self] in window.canUseNumberShortcuts = { [weak self] in
guard let self else { return false } guard let self else { return false }
@@ -122,7 +126,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
uninstallReview?.goBack() uninstallReview?.goBack()
return return
} }
closeCommand() guard closeCommand() else { return }
actions.close(animated: false) actions.close(animated: false)
(window as? LauncherPanel)?.beginNumberHintSession() (window as? LauncherPanel)?.beginNumberHintSession()
let frontmost = NSWorkspace.shared.frontmostApplication let frontmost = NSWorkspace.shared.frontmostApplication
@@ -140,7 +144,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
showWindow(nil) showWindow(nil)
NSApp.activate() NSApp.activate()
window?.makeKeyAndOrderFront(nil) window?.makeKeyAndOrderFront(nil)
window?.makeFirstResponder(searchField) if let notes { notes.focusEditor() }
else { window?.makeFirstResponder(searchField) }
(window as? LauncherPanel)?.sampleNumberHints() (window as? LauncherPanel)?.sampleNumberHints()
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent (searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
refreshApplications() refreshApplications()
@@ -164,7 +169,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
uninstallReview?.goBack() uninstallReview?.goBack()
return return
} }
closeCommand() guard closeCommand() else { return }
actions.close(animated: false) actions.close(animated: false)
alignmentGuides?.end() alignmentGuides?.end()
let previous = previousApplication let previous = previousApplication
@@ -567,10 +572,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.locationLabel.isHidden = true cell.locationLabel.isHidden = true
cell.kindLabel.attributedStringValue = history.isFavorite(entry) cell.kindLabel.attributedStringValue = history.isFavorite(entry)
? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind ? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind
cell.imageView?.image = entry.command == .emoji ? Self.emojiIcon switch entry.command {
: NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil) 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.imageView?.contentTintColor = entry.command == .emoji ? nil : SunsetPalette.accent
cell.toolTip = entry.command == .emoji ? "Search and copy emoji" : "Translate English to Japanese"
} else { } else {
let application = results[row] let application = results[row]
cell.textField?.stringValue = application.name cell.textField?.stringValue = application.name
@@ -794,11 +807,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@objc private func goBackCommand() { @objc private func goBackCommand() {
translation?.goBack() translation?.goBack()
emojiPicker?.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() translation?.goBack()
emojiPicker?.close() emojiPicker?.close()
return true
} }
private func showCommand(_ command: Application.Command) { private func showCommand(_ command: Application.Command) {
@@ -825,6 +845,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
controller = tool controller = tool
placeholder = "Search emoji by name or :shortcode:…" placeholder = "Search emoji by name or :shortcode:…"
accessibilityLabel = "Search emoji" 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 previousQuery = searchField.stringValue
let previousPlaceholder = searchField.placeholderAttributedString let previousPlaceholder = searchField.placeholderAttributedString
@@ -833,6 +859,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
controller.view.removeFromSuperview() controller.view.removeFromSuperview()
self.translation = nil self.translation = nil
self.emojiPicker = nil self.emojiPicker = nil
self.notes = nil
self.searchField.isEnabled = true
self.activeCommand = nil self.activeCommand = nil
self.commandBack.isHidden = true self.commandBack.isHidden = true
self.commandBack.target = nil self.commandBack.target = nil
@@ -851,6 +879,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
translation?.closed = close translation?.closed = close
emojiPicker?.closed = close emojiPicker?.closed = close
notes?.closed = close
actions.close(animated: false) actions.close(animated: false)
(window as? LauncherPanel)?.resetNumberHints() (window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = "" openButton.keyEquivalent = ""
@@ -864,6 +893,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:] attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
) )
searchField.setAccessibilityLabel(accessibilityLabel) searchField.setAccessibilityLabel(accessibilityLabel)
if command == .notes {
searchField.stringValue = "Conjure Notes"
searchField.isEnabled = false
}
for view in launcherViews.dropFirst() { for view in launcherViews.dropFirst() {
view.isHidden = true view.isHidden = true
view.setAccessibilityHidden(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.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 16),
controller.view.bottomAnchor.constraint(equalTo: content.bottomAnchor), 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 numberHintsChanged: (() -> Void)?
var launchNumber: ((Int) -> Void)? var launchNumber: ((Int) -> Void)?
var commandNavigation: ((String) -> Bool)? var commandNavigation: ((String) -> Bool)?
var usesTextNavigation: (() -> Bool)?
private var numberShortcuts = CommandNumberShortcuts() private var numberShortcuts = CommandNumberShortcuts()
private var commandHoldTask: Task<Void, Never>? private var commandHoldTask: Task<Void, Never>?
var numberHintsVisible: Bool { numberShortcuts.isVisible } var numberHintsVisible: Bool { numberShortcuts.isVisible }
@@ -1218,7 +1253,7 @@ final class LauncherPanel: NSPanel {
} }
resetNumberHints(modifierFlags: event.modifierFlags) 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 { event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return } if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return }
switch event.charactersIgnoringModifiers?.lowercased() { switch event.charactersIgnoringModifiers?.lowercased() {
+280
View File
@@ -0,0 +1,280 @@
import AppKit
import ConjureKit
import UniformTypeIdentifiers
import Highlighter
@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]+`"), [.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]
}
}
+2
View File
@@ -4,6 +4,7 @@ public struct Application: Identifiable, Sendable {
public enum Command: String, CaseIterable, Sendable { public enum Command: String, CaseIterable, Sendable {
case translate case translate
case emoji case emoji
case notes
} }
public static let commands = Command.allCases.map { Application(command: $0) } public static let commands = Command.allCases.map { Application(command: $0) }
public let command: Command? public let command: Command?
@@ -22,6 +23,7 @@ public struct Application: Identifiable, Sendable {
let name = switch command { let name = switch command {
case .translate: "Conjure Translate" case .translate: "Conjure Translate"
case .emoji: "Conjure Emoji" case .emoji: "Conjure Emoji"
case .notes: "Conjure Notes"
} }
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command) 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 "c": return "Copy"
case "v": return "Paste" case "v": return "Paste"
case "a": return "Select All" case "a": return "Select All"
case "n": return "New Note"
case "z": return "Undo"
case "m": return "Minimize" case "m": return "Minimize"
case "k": return "entry actions" case "k": return "entry actions"
case "return", "enter": return "Open, Copy, Translate, or confirm uninstall" 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" { } else if candidate.modifiers == [.command, .option], key == "h" {
return "Hide Others" return "Hide Others"
} else if candidate.modifiers == [.command, .shift], key == "z" {
return "Redo"
} else if candidate.modifiers == .control { } else if candidate.modifiers == .control {
switch key { switch key {
case "h", "j", "k", "l": return "launcher and emoji navigation" 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)
}
}
+2 -1
View File
@@ -5,9 +5,10 @@
- [x] remove window open/fade in/transition animation when opening conjure from keybindings - [x] remove window open/fade in/transition animation when opening conjure from keybindings
- this is already the case for the application launcher window. - this is already the case for the application launcher window.
- evidently not the case for the emoji picker (though it should be ubiquitous) - evidently not the case for the emoji picker (though it should be ubiquitous)
- [ ] add a notes app inspired by raycast notes - [x] add a notes app inspired by raycast notes
- support multiple different notes - support multiple different notes
- support switching between notes - support switching between notes
- highlight markdown syntax - highlight markdown syntax
- allow images - allow images
- [ ] put the border already in use on some elements around the emoji containers in the emoji picker.
- [ ] improve on the conjure translation command - [ ] improve on the conjure translation command
@@ -0,0 +1,31 @@
import AppKit
import Testing
@testable import Conjure
@Test @MainActor func fencedCodeHighlightsTokensWithoutChangingSourceOrApplyingMarkdown() throws {
_ = NSApplication.shared
for fence in ["```", "~~~~"] {
let text = "# Heading 🦊\n\(fence)py\ndef main():\n # comment\n return \"**not bold**\"\n\(fence)\nAfter\n```unknown-language\n# literal\n```"
let editor = NSTextView()
editor.string = text
MarkdownHighlighting.apply(to: editor)
let storage = try #require(editor.textStorage)
func attributes(_ token: String) -> [NSAttributedString.Key: Any] {
storage.attributes(at: (text as NSString).range(of: token).location, effectiveRange: nil)
}
let keyword = try #require(attributes("def")[.foregroundColor] as? NSColor)
let name = try #require(attributes("main")[.foregroundColor] as? NSColor)
let comment = try #require(attributes("# comment")[.foregroundColor] as? NSColor)
#expect(keyword != name)
#expect(comment != keyword)
#expect((attributes("# comment")[.font] as? NSFont)?.pointSize == 14)
#expect((attributes("**not bold**")[.font] as? NSFont)?.isFixedPitch == true)
#expect((attributes("# literal")[.font] as? NSFont)?.pointSize == 14)
#expect((attributes("After")[.font] as? NSFont)?.isFixedPitch == false)
#expect(storage.string == text)
editor.string = "```python\ndef main():\n pass"
MarkdownHighlighting.apply(to: editor)
let unfinished = try #require(editor.textStorage)
#expect(unfinished.attribute(.foregroundColor, at: 10, effectiveRange: nil) as? NSColor == keyword)
}
}
@@ -55,8 +55,6 @@ import Testing
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first) let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
search.stringValue = "ct" search.stringValue = "ct"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search)) launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first)
#expect(table.numberOfRows == 1)
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:))) _ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
#expect(search.accessibilityLabel() == "English text to translate into Japanese") #expect(search.accessibilityLabel() == "English text to translate into Japanese")
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:))) _ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
@@ -3,22 +3,27 @@ import ConjureKit
import Testing import Testing
@testable import Conjure @testable import Conjure
@Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "translate-key"]) @Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "translate-key", "notes-editor"])
@MainActor func escapeDismissesEntireLauncher(_ destination: String) throws { @MainActor func escapeDismissesEntireLauncher(_ destination: String) throws {
_ = NSApplication.shared _ = NSApplication.shared
let suite = "ConjureEscape.\(UUID().uuidString)" let suite = "ConjureEscape.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) } let notesDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: notesDirectory)
}
let launcher = LauncherWindowController( let launcher = LauncherWindowController(
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }), history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
defaults: defaults) defaults: defaults, notesDirectory: notesDirectory)
let window = try #require(launcher.window) let window = try #require(launcher.window)
defer { window.orderOut(nil) } defer { window.orderOut(nil) }
let content = try #require(window.contentView) let content = try #require(window.contentView)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first) let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
if !destination.hasPrefix("launcher") { if !destination.hasPrefix("launcher") {
search.stringValue = destination.hasPrefix("emoji") ? "emoji" : "translate" search.stringValue = destination.hasPrefix("emoji") ? "emoji"
: destination.hasPrefix("notes") ? "notes" : "translate"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search)) launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:))) _ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
} }
@@ -48,6 +53,9 @@ import Testing
_ = provider.target?.perform(provider.action, with: provider) _ = provider.target?.perform(provider.action, with: provider)
let key = try #require(descendants(content).compactMap { $0 as? NSSecureTextField }.first) let key = try #require(descendants(content).compactMap { $0 as? NSSecureTextField }.first)
window.makeFirstResponder(key) window.makeFirstResponder(key)
} else if destination == "notes-editor" {
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
window.makeFirstResponder(editor)
} }
#expect(window.isVisible) #expect(window.isVisible)
let escape = try #require(NSEvent.keyEvent( let escape = try #require(NSEvent.keyEvent(
@@ -0,0 +1,81 @@
import AppKit
import ConjureKit
import Testing
@testable import Conjure
@Test @MainActor
func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws {
_ = NSApplication.shared
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let suite = "NotesEditing.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer {
try? FileManager.default.removeItem(at: directory)
defaults.removePersistentDomain(forName: suite)
}
let controller = NotesController(defaults: defaults, directory: directory)
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390), styleMask: [.borderless], backing: .buffered, defer: false)
window.contentViewController = controller
defer { window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let views = descendants(controller.view)
let editor = try #require(views.compactMap { $0 as? NotesTextView }.first)
let picker = try #require(views.compactMap { $0 as? NSPopUpButton }.first)
let create = try #require(views.compactMap { $0 as? NSButton }.first { $0.title == "New Note" })
let firstID = try #require(picker.selectedItem?.representedObject as? UUID)
// Empty notes have duplicate titles; both must remain individually selectable.
create.performClick(nil)
#expect(picker.numberOfItems == 2)
let secondID = try #require(picker.selectedItem?.representedObject as? UUID)
#expect(secondID != firstID)
editor.insertText("# Second 🦊\n**bold** and `code`", replacementRange: editor.selectedRange())
#expect((editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)?.pointSize == 17)
let codeRange = (editor.string as NSString).range(of: "`code`")
let codeFont = try #require(editor.textStorage?.attribute(.font, at: codeRange.location, effectiveRange: nil) as? NSFont)
#expect(codeFont.isFixedPitch)
let firstIndex = try #require(picker.itemArray.firstIndex { $0.representedObject as? UUID == firstID })
picker.selectItem(at: firstIndex)
_ = NSApp.sendAction(try #require(picker.action), to: picker.target, from: picker)
#expect(editor.string.isEmpty)
#expect(editor.undoManager?.canUndo != true)
editor.insertText("First note", replacementRange: editor.selectedRange())
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
bitmap.setColor(.red, atX: 0, y: 0)
let imageURL = directory.appendingPathComponent("image.png")
try #require(bitmap.representation(using: .png, properties: [:])).write(to: imageURL)
try editor.insertImage(at: imageURL)
#expect(controller.save())
let restored = try NotesStore(directory: directory)
let first = try #require(restored.notes.first { $0.id == firstID })
#expect(first.contents.string == "First note\u{fffc}")
#expect(first.contents.attribute(.attachment, at: 10, effectiveRange: nil) is NSTextAttachment)
#expect(restored.notes.first { $0.id == secondID }?.contents.string == "# Second 🦊\n**bold** and `code`")
let reopened = NotesController(defaults: defaults, directory: directory)
let reopenedViews = descendants(reopened.view)
let reopenedEditor = try #require(reopenedViews.compactMap { $0 as? NotesTextView }.first)
#expect(reopenedEditor.string == first.contents.string)
// Removing Markdown must also remove its previous visual style without changing the source.
reopenedEditor.insertText("Plain text", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count))
#expect((reopenedEditor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)?.pointSize == 14)
#expect(reopenedEditor.string == "Plain text")
let backup = directory.appendingPathExtension("backup")
defer { try? FileManager.default.removeItem(at: backup) }
try FileManager.default.moveItem(at: directory, to: backup)
try Data("Blocked storage".utf8).write(to: directory)
reopenedEditor.insertText("Unsaved", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count))
var closed = false
reopened.closed = { closed = true }
reopened.goBack()
#expect(!closed)
#expect(!reopened.save())
let reopenedPicker = try #require(reopenedViews.compactMap { $0 as? NSPopUpButton }.first)
reopenedPicker.selectItem(at: try #require(reopenedPicker.itemArray.firstIndex { $0.representedObject as? UUID == secondID }))
_ = NSApp.sendAction(try #require(reopenedPicker.action), to: reopenedPicker.target, from: reopenedPicker)
#expect(reopenedEditor.string == "Unsaved")
#expect(reopenedPicker.selectedItem?.representedObject as? UUID == firstID)
try FileManager.default.removeItem(at: directory)
try FileManager.default.moveItem(at: backup, to: directory)
#expect(reopened.save())
reopened.goBack()
#expect(closed)
}
+103
View File
@@ -0,0 +1,103 @@
import AppKit
import ConjureKit
import Testing
@Test @MainActor
func notesRoundTripIndependentMarkdownUnicodeAndImageContents() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = try NotesStore(directory: directory)
let firstID = try store.create()
let secondID = try store.create()
#expect(store.notes.count == 2)
#expect(store.notes.allSatisfy { $0.title == "Untitled Note" })
let bitmap = try #require(NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
))
bitmap.setColor(.red, atX: 0, y: 0)
let png = try #require(bitmap.representation(using: .png, properties: [:]))
let imageFile = FileWrapper(regularFileWithContents: png)
imageFile.preferredFilename = "pixel.png"
let attachment = NSTextAttachment(fileWrapper: imageFile)
let first = NSMutableAttributedString(string: "\n # 日本語 🦊 \n**bold** and `code`\n")
first.append(NSAttributedString(attachment: attachment))
let expectedFirstText = first.string
try store.update(id: firstID, contents: first)
first.mutableString.setString("Mutated caller buffer")
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
#expect(store.notes.first { $0.id == firstID }?.title == "# 日本語 🦊")
try store.update(id: secondID, contents: NSAttributedString(string: "Second note"))
try store.update(id: secondID, contents: NSAttributedString(string: "## Edited\n- café\n- Ελληνικά"))
#expect(store.notes.first { $0.id == firstID }?.contents.string == expectedFirstText)
let reloaded = try NotesStore(directory: directory)
#expect(Set(reloaded.notes.map(\.id)) == Set([firstID, secondID]))
let restoredFirst = try #require(reloaded.notes.first { $0.id == firstID })
#expect(restoredFirst.contents.string == expectedFirstText)
#expect(restoredFirst.title == "# 日本語 🦊")
let restoredAttachment = try #require(restoredFirst.contents.attribute(
.attachment, at: restoredFirst.contents.length - 1, effectiveRange: nil
) as? NSTextAttachment)
let restoredPNG = try #require(restoredAttachment.fileWrapper?.regularFileContents)
#expect(restoredPNG == png)
#expect(NSImage(data: restoredPNG) != nil)
#expect(reloaded.notes.first { $0.id == secondID }?.contents.string == "## Edited\n- café\n- Ελληνικά")
}
@Test @MainActor
func notesWriteFailuresPreserveSavedMemoryAndDiskContents() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let directory = root.appendingPathComponent("Notes")
let backup = root.appendingPathComponent("SavedNotes")
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: NSAttributedString(string: "Saved contents"))
// A regular file in place of the parent directory fails even when tests run as root.
try FileManager.default.moveItem(at: directory, to: backup)
try Data("not a directory".utf8).write(to: directory)
#expect(throws: (any Error).self) {
try store.update(id: id, contents: NSAttributedString(string: "Unsaved changes"))
}
#expect(throws: (any Error).self) { try store.create() }
#expect(store.notes.count == 1)
#expect(store.notes.first?.contents.string == "Saved contents")
#expect(store.notes.first?.title == "Saved contents")
try FileManager.default.removeItem(at: directory)
try FileManager.default.moveItem(at: backup, to: directory)
let reloaded = try NotesStore(directory: directory)
#expect(reloaded.notes.first?.id == id)
#expect(reloaded.notes.first?.contents.string == "Saved contents")
}
@Test @MainActor
func corruptPersistedNotesRaiseErrorsWithoutReplacement() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let package = directory.appendingPathComponent(UUID().uuidString + ".rtfd")
try FileManager.default.createDirectory(at: package, withIntermediateDirectories: true)
let corruptRTF = package.appendingPathComponent("TXT.rtf")
let bytes = Data("This is not an RTF document".utf8)
try bytes.write(to: corruptRTF)
#expect(throws: (any Error).self) { try NotesStore(directory: directory) }
#expect(try Data(contentsOf: corruptRTF) == bytes)
}
@Test @MainActor
func noteTitlesUseFirstNonemptyLineAndCapAtSixtyCharacters() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let store = try NotesStore(directory: directory)
let id = try store.create()
try store.update(id: id, contents: NSAttributedString(string: "\n \t\n " + String(repeating: "🦊", count: 70) + " \nIgnored"))
#expect(store.notes.first?.title == String(repeating: "🦊", count: 60))
try store.update(id: id, contents: NSAttributedString(string: " \t\n\n"))
#expect(store.notes.first?.title == "Untitled Note")
}
@@ -38,10 +38,14 @@ import Testing
_ = NSApplication.shared _ = NSApplication.shared
let suite = "ConjurePresentation.\(UUID().uuidString)" let suite = "ConjurePresentation.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) } let notesDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: notesDirectory)
}
let launcher = LauncherWindowController( let launcher = LauncherWindowController(
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }), history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
defaults: defaults) defaults: defaults, notesDirectory: notesDirectory)
let window = try #require(launcher.window) let window = try #require(launcher.window)
defer { window.orderOut(nil) } defer { window.orderOut(nil) }
let content = try #require(window.contentView) let content = try #require(window.contentView)
@@ -60,6 +64,7 @@ import Testing
switch command { switch command {
case .emoji: $0 is NSCollectionView case .emoji: $0 is NSCollectionView
case .translate: $0.accessibilityLabel() == "Japanese translation" case .translate: $0.accessibilityLabel() == "Japanese translation"
case .notes: $0.accessibilityLabel() == "Note editor"
} }
} }
}) })