diff --git a/README.md b/README.md index 361f8ec..38e6fae 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Right-click the launcher background for Settings and Quit. Actions include favor Action and provider popups paint a popup-sized background using the launcher window’s gradient coordinates, keeping the glows continuous without showing the results underneath. Each opening rebuilds the complete native view and layer tree, rather than resizing a retained presentation between the footer and header. Callbacks and selection behavior stay in the controller. Layout finishes before the opening fade, including when switching popup types. -The launcher, emoji picker, and Translate share `ActionPill` for footer styling, alignment, spacing, and keyboard indicators: primary action with Return first, then Actions with separate Command and K keycaps. Controllers supply the buttons and retain their targets, enabled state, and popup behavior. +The launcher, emoji picker, Notes, and Translate share `ActionPill` for footer styling, alignment, spacing, and keyboard indicators: primary action first, then Actions with separate Command and K keycaps. Notes uses Command-N for its primary action so Return remains available for editing; the other tools use Return. Controllers supply the buttons and retain their targets, enabled state, and popup behavior. The header’s trailing edge is constrained to the footer’s trailing edge. The translation provider pill and footer pill therefore share the same 8-point right inset and 36-point height. @@ -134,9 +134,9 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa ## Notes -Search for **Conjure Notes**, identified by its centered 📔 icon, 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. +Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. The title uses full-contrast text. The footer action pill offers **New Note** (Command-N) and **Actions** (Command-K), which opens a native note-selection menu with the current note checked. 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. +Edit Markdown directly: headings, bold, italic, lists, links, inline code, and fenced code blocks are highlighted while the source remains visible. Paste images or drag image files into the editor to embed them locally; no file-picker button is needed. Return inserts a newline. 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. diff --git a/Sources/Conjure/ActionPill.swift b/Sources/Conjure/ActionPill.swift index c1ec855..738a714 100644 --- a/Sources/Conjure/ActionPill.swift +++ b/Sources/Conjure/ActionPill.swift @@ -2,7 +2,7 @@ import AppKit @MainActor final class ActionPill: NSView { - init(primaryButton: NSButton, actionsButton: NSButton) { + init(primaryButton: NSButton, actionsButton: NSButton, primaryCommand: String? = nil) { super.init(frame: .zero) wantsLayer = true layer?.cornerRadius = 18 @@ -14,12 +14,12 @@ final class ActionPill: NSView { button.font = .systemFont(ofSize: 12, weight: .medium) button.contentTintColor = SunsetPalette.text } - primaryButton.keyEquivalent = "\r" - primaryButton.keyEquivalentModifierMask = [] + primaryButton.keyEquivalent = primaryCommand ?? "\r" + primaryButton.keyEquivalentModifierMask = primaryCommand == nil ? [] : .command actionsButton.title = "Actions" actionsButton.keyEquivalent = "k" actionsButton.keyEquivalentModifierMask = .command - let returnKey = ShortcutIndicator("↵") + let returnKey = primaryCommand.map { ShortcutIndicator("⌘", $0.uppercased()) } ?? ShortcutIndicator("↵") let actionKeys = ShortcutIndicator("⌘", "K") for control in [primaryButton, returnKey, actionsButton, actionKeys] { control.translatesAutoresizingMaskIntoConstraints = false diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 1d49610..fedc47b 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -6,6 +6,7 @@ import ApplicationServices @MainActor final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { private let searchField = NSSearchField() + private let commandTitle = NSTextField(labelWithString: "Conjure Notes") private let searchSymbol = IconView(font: .systemFont(ofSize: 20)) private let commandBack = NSButton(title: "", target: nil, action: nil) private let translationHeader = NSStackView() @@ -262,6 +263,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont] ) searchField.textColor = SunsetPalette.text + commandTitle.font = searchFont + commandTitle.textColor = SunsetPalette.text + commandTitle.isHidden = true searchField.isBordered = false searchField.drawsBackground = false searchField.focusRingType = .none @@ -288,7 +292,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)), ]) translationHeader.isHidden = true - let header = NSStackView(views: [searchSymbol, commandBack, searchField, translationHeader]) + let header = NSStackView(views: [searchSymbol, commandBack, searchField, commandTitle, translationHeader]) header.orientation = .horizontal header.alignment = .centerY header.spacing = 12 @@ -899,7 +903,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe self.translation = nil self.emojiPicker = nil self.notes = nil - self.searchField.isEnabled = true + self.searchField.isHidden = false + self.commandTitle.isHidden = true self.activeCommand = nil self.commandBack.isHidden = true self.commandBack.target = nil @@ -935,8 +940,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe ) searchField.setAccessibilityLabel(accessibilityLabel) if command == .notes { - searchField.stringValue = "Conjure Notes" - searchField.isEnabled = false + searchField.isHidden = true + commandTitle.isHidden = false } for view in launcherViews.dropFirst() { view.isHidden = true @@ -951,7 +956,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe NSLayoutConstraint.activate([ controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor), controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor), - controller.view.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 16), + controller.view.topAnchor.constraint(equalTo: command == .notes ? commandTitle.bottomAnchor : searchField.bottomAnchor, constant: 16), controller.view.bottomAnchor.constraint(equalTo: content.bottomAnchor), ]) if let notes { notes.focusEditor() } diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index 2e49fc9..a886278 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -1,12 +1,13 @@ import AppKit import ConjureKit -import UniformTypeIdentifiers import Highlighter @MainActor final class NotesController: NSViewController, NSTextViewDelegate { - private let notePicker = NSPopUpButton() - private let editor = NotesTextView() + private let createButton = NSButton(title: "New Note", target: nil, action: nil) + private let actionsButton = NSButton(title: "Actions", target: nil, action: nil) + private let noteMenu = NSMenu(title: "Notes") + private let editor = NSTextView() private let status = NSTextField(labelWithString: "") private let defaults: UserDefaults private var store: NotesStore? @@ -29,16 +30,19 @@ final class NotesController: NSViewController, NSTextViewDelegate { 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) + createButton.target = self + createButton.action = #selector(newNote) + createButton.toolTip = "Create a note (Command-N)" + actionsButton.target = self + actionsButton.action = #selector(showActions) + actionsButton.setAccessibilityLabel("Choose a note") + actionsButton.toolTip = "Choose a note (Command-K)" + actionsButton.menu = noteMenu + let pill = ActionPill(primaryButton: createButton, actionsButton: actionsButton, primaryCommand: "n") + let footer = NSStackView(views: [status, NSView(), pill]) + footer.orientation = .horizontal + footer.alignment = .centerY + footer.spacing = 12 editor.delegate = self editor.isRichText = true @@ -70,26 +74,24 @@ final class NotesController: NSViewController, NSTextViewDelegate { status.textColor = SunsetPalette.secondaryText status.setAccessibilityLabel("Notes save status") status.lineBreakMode = .byTruncatingTail - for control in [header, scroll, status] { + status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + for control in [scroll, footer] { 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), + scroll.topAnchor.constraint(equalTo: view.topAnchor), + scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8), + footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), + footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8), + footer.heightAnchor.constraint(equalToConstant: 36), ]) guard let store else { editor.isEditable = false - for control in [notePicker, create, image] { control.isEnabled = false } + for control in [createButton, actionsButton] { control.isEnabled = false } showError(loadError!) return } @@ -117,8 +119,8 @@ final class NotesController: NSViewController, NSTextViewDelegate { do { try store.update(id: selectedID, contents: editor.attributedString()) isDirty = false - updatePicker() - status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image" + updateActions() + status.stringValue = "Saved locally · Markdown · Paste or drop images" status.textColor = SunsetPalette.secondaryText return true } catch { @@ -138,9 +140,8 @@ final class NotesController: NSViewController, NSTextViewDelegate { catch { showError(error) } } - @objc private func switchNote() { - guard let value = notePicker.selectedItem?.representedObject as? UUID else { return } - guard save() else { updatePicker(); return } + @objc private func switchNote(_ sender: NSMenuItem) { + guard let value = sender.representedObject as? UUID, save() else { return } select(value) focusEditor() } @@ -154,17 +155,18 @@ final class NotesController: NSViewController, NSTextViewDelegate { 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" + updateActions() + status.stringValue = "Saved locally · Markdown · Paste or drop images" status.textColor = SunsetPalette.secondaryText } - private func updatePicker() { - notePicker.removeAllItems() + private func updateActions() { + noteMenu.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) } + let item = noteMenu.addItem(withTitle: note.title, action: #selector(switchNote(_:)), keyEquivalent: "") + item.target = self + item.representedObject = note.id + item.state = note.id == selectedID ? .on : .off } } @@ -174,30 +176,9 @@ final class NotesController: NSViewController, NSTextViewDelegate { 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()) + @objc private func showActions() { + noteMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: actionsButton.bounds.maxY + 4), in: actionsButton) + focusEditor() } } diff --git a/TODO.md b/TODO.md index a69d5bf..e69de29 100644 --- a/TODO.md +++ b/TODO.md @@ -1,14 +0,0 @@ -- [x] bugfix: the emoji picker currently doesn't scroll with the selection (arrows or ctrl+hjkl), only with mouse scroll. - - the window should scroll with the current selection -- [x] make escape close the window that was opened, be it the application launcher, the emoji picker, or something else. - - clicking the "back" chevron should still take you back to the application launcher view though (as it does today). -- [x] remove window open/fade in/transition animation when opening conjure from keybindings - - this is already the case for the application launcher window. - - evidently not the case for the emoji picker (though it should be ubiquitous) -- [x] add a notes app inspired by raycast notes - - support multiple different notes - - support switching between notes - - highlight markdown syntax - - allow images -- [x] put the border already in use on some elements around the emoji containers in the emoji picker. -- [x] improve on the conjure translation command \ No newline at end of file diff --git a/Tests/ConjureKitTests/EscapeDismissalTests.swift b/Tests/ConjureKitTests/EscapeDismissalTests.swift index 54c4980..91f5971 100644 --- a/Tests/ConjureKitTests/EscapeDismissalTests.swift +++ b/Tests/ConjureKitTests/EscapeDismissalTests.swift @@ -59,7 +59,9 @@ import Testing }) window.makeFirstResponder(output) } else if destination == "notes-editor" { - let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first) + let editor = try #require(descendants(content).compactMap { $0 as? NSTextView }.first { + $0.accessibilityLabel() == "Note editor" + }) window.makeFirstResponder(editor) } #expect(window.isVisible) diff --git a/Tests/ConjureKitTests/NotesEditingTests.swift b/Tests/ConjureKitTests/NotesEditingTests.swift index 974d7a1..a00082a 100644 --- a/Tests/ConjureKitTests/NotesEditingTests.swift +++ b/Tests/ConjureKitTests/NotesEditingTests.swift @@ -19,40 +19,55 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws 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) + let editor = try #require(views.compactMap { $0 as? NSTextView }.first) + let actions = try #require(views.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Choose a note" }) + let menu = try #require(actions.menu) + let firstID = try #require(menu.items.first { $0.state == .on }?.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) + window.makeFirstResponder(editor) + let newNoteKey = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, + modifierFlags: .command, timestamp: 1, windowNumber: window.windowNumber, context: nil, + characters: "n", charactersIgnoringModifiers: "n", isARepeat: false, keyCode: 45)) + #expect(window.performKeyEquivalent(with: newNoteKey)) + let secondID = try #require(menu.items.first { $0.state == .on }?.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) + menu.performActionForItem(at: try #require(menu.items.firstIndex { $0.representedObject as? UUID == firstID })) #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) + let png = try #require(bitmap.representation(using: .png, properties: [:])) + try png.write(to: imageURL) + let pasteboard = NSPasteboard.withUniqueName() + defer { pasteboard.releaseGlobally() } + pasteboard.setData(png, forType: .png) + #expect(editor.readSelection(from: pasteboard)) + pasteboard.clearContents() + pasteboard.writeObjects([imageURL as NSURL]) + #expect(editor.readSelection(from: pasteboard)) #expect(controller.save()) + let returnKey = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, + modifierFlags: [], timestamp: 2, windowNumber: window.windowNumber, context: nil, + characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)) + editor.keyDown(with: returnKey) + #expect(editor.string == "First note\u{fffc}\u{fffc}\n") + #expect(menu.items.first { $0.state == .on }?.representedObject as? UUID == firstID) 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.string == "First note\u{fffc}\u{fffc}\n") #expect(first.contents.attribute(.attachment, at: 10, effectiveRange: nil) is NSTextAttachment) + #expect(first.contents.attribute(.attachment, at: 11, 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) + let reopenedEditor = try #require(reopenedViews.compactMap { $0 as? NSTextView }.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)) @@ -68,11 +83,11 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws 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) + let reopenedActions = try #require(reopenedViews.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Choose a note" }) + let reopenedMenu = try #require(reopenedActions.menu) + reopenedMenu.performActionForItem(at: try #require(reopenedMenu.items.firstIndex { $0.representedObject as? UUID == secondID })) #expect(reopenedEditor.string == "Unsaved") - #expect(reopenedPicker.selectedItem?.representedObject as? UUID == firstID) + #expect(reopenedMenu.items.first { $0.state == .on }?.representedObject as? UUID == firstID) try FileManager.default.removeItem(at: directory) try FileManager.default.moveItem(at: backup, to: directory) #expect(reopened.save())