Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 433c5c3c2a | |||
| e36a8bbc6b |
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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"]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user