style: align notes controls with action pills

This commit is contained in:
Valdemar Lorenzen
2026-10-19 09:51:00 +02:00
parent fcd2d1229f
commit 0b5d27752c
7 changed files with 96 additions and 107 deletions
+3 -3
View File
@@ -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.
+4 -4
View File
@@ -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
+10 -5
View File
@@ -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() }
+43 -62
View File
@@ -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()
}
}
-14
View File
@@ -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
@@ -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)
+33 -18
View File
@@ -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())