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. 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. 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 ## 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. 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 @MainActor
final class ActionPill: NSView { final class ActionPill: NSView {
init(primaryButton: NSButton, actionsButton: NSButton) { init(primaryButton: NSButton, actionsButton: NSButton, primaryCommand: String? = nil) {
super.init(frame: .zero) super.init(frame: .zero)
wantsLayer = true wantsLayer = true
layer?.cornerRadius = 18 layer?.cornerRadius = 18
@@ -14,12 +14,12 @@ final class ActionPill: NSView {
button.font = .systemFont(ofSize: 12, weight: .medium) button.font = .systemFont(ofSize: 12, weight: .medium)
button.contentTintColor = SunsetPalette.text button.contentTintColor = SunsetPalette.text
} }
primaryButton.keyEquivalent = "\r" primaryButton.keyEquivalent = primaryCommand ?? "\r"
primaryButton.keyEquivalentModifierMask = [] primaryButton.keyEquivalentModifierMask = primaryCommand == nil ? [] : .command
actionsButton.title = "Actions" actionsButton.title = "Actions"
actionsButton.keyEquivalent = "k" actionsButton.keyEquivalent = "k"
actionsButton.keyEquivalentModifierMask = .command actionsButton.keyEquivalentModifierMask = .command
let returnKey = ShortcutIndicator("↵") let returnKey = primaryCommand.map { ShortcutIndicator("⌘", $0.uppercased()) } ?? ShortcutIndicator("↵")
let actionKeys = ShortcutIndicator("⌘", "K") let actionKeys = ShortcutIndicator("⌘", "K")
for control in [primaryButton, returnKey, actionsButton, actionKeys] { for control in [primaryButton, returnKey, actionsButton, actionKeys] {
control.translatesAutoresizingMaskIntoConstraints = false control.translatesAutoresizingMaskIntoConstraints = false
+10 -5
View File
@@ -6,6 +6,7 @@ import ApplicationServices
@MainActor @MainActor
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private let searchField = NSSearchField() private let searchField = NSSearchField()
private let commandTitle = NSTextField(labelWithString: "Conjure Notes")
private let searchSymbol = IconView(font: .systemFont(ofSize: 20)) private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
private let commandBack = NSButton(title: "", target: nil, action: nil) private let commandBack = NSButton(title: "", target: nil, action: nil)
private let translationHeader = NSStackView() private let translationHeader = NSStackView()
@@ -262,6 +263,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont] attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
) )
searchField.textColor = SunsetPalette.text searchField.textColor = SunsetPalette.text
commandTitle.font = searchFont
commandTitle.textColor = SunsetPalette.text
commandTitle.isHidden = true
searchField.isBordered = false searchField.isBordered = false
searchField.drawsBackground = false searchField.drawsBackground = false
searchField.focusRingType = .none searchField.focusRingType = .none
@@ -288,7 +292,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)), commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
]) ])
translationHeader.isHidden = true translationHeader.isHidden = true
let header = NSStackView(views: [searchSymbol, commandBack, searchField, translationHeader]) let header = NSStackView(views: [searchSymbol, commandBack, searchField, commandTitle, translationHeader])
header.orientation = .horizontal header.orientation = .horizontal
header.alignment = .centerY header.alignment = .centerY
header.spacing = 12 header.spacing = 12
@@ -899,7 +903,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
self.translation = nil self.translation = nil
self.emojiPicker = nil self.emojiPicker = nil
self.notes = nil self.notes = nil
self.searchField.isEnabled = true self.searchField.isHidden = false
self.commandTitle.isHidden = true
self.activeCommand = nil self.activeCommand = nil
self.commandBack.isHidden = true self.commandBack.isHidden = true
self.commandBack.target = nil self.commandBack.target = nil
@@ -935,8 +940,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
) )
searchField.setAccessibilityLabel(accessibilityLabel) searchField.setAccessibilityLabel(accessibilityLabel)
if command == .notes { if command == .notes {
searchField.stringValue = "Conjure Notes" searchField.isHidden = true
searchField.isEnabled = false commandTitle.isHidden = false
} }
for view in launcherViews.dropFirst() { for view in launcherViews.dropFirst() {
view.isHidden = true view.isHidden = true
@@ -951,7 +956,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor), controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor), 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), controller.view.bottomAnchor.constraint(equalTo: content.bottomAnchor),
]) ])
if let notes { notes.focusEditor() } if let notes { notes.focusEditor() }
+43 -62
View File
@@ -1,12 +1,13 @@
import AppKit import AppKit
import ConjureKit import ConjureKit
import UniformTypeIdentifiers
import Highlighter import Highlighter
@MainActor @MainActor
final class NotesController: NSViewController, NSTextViewDelegate { final class NotesController: NSViewController, NSTextViewDelegate {
private let notePicker = NSPopUpButton() private let createButton = NSButton(title: "New Note", target: nil, action: nil)
private let editor = NotesTextView() 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 status = NSTextField(labelWithString: "")
private let defaults: UserDefaults private let defaults: UserDefaults
private var store: NotesStore? private var store: NotesStore?
@@ -29,16 +30,19 @@ final class NotesController: NSViewController, NSTextViewDelegate {
override func loadView() { override func loadView() {
view = NSView() view = NSView()
notePicker.target = self createButton.target = self
notePicker.action = #selector(switchNote) createButton.action = #selector(newNote)
notePicker.setAccessibilityLabel("Choose a note") createButton.toolTip = "Create a note (Command-N)"
let create = NSButton(title: "New Note", target: self, action: #selector(newNote)) actionsButton.target = self
create.keyEquivalent = "n" actionsButton.action = #selector(showActions)
create.keyEquivalentModifierMask = .command actionsButton.setAccessibilityLabel("Choose a note")
let image = NSButton(title: "Insert Image…", target: self, action: #selector(insertImage)) actionsButton.toolTip = "Choose a note (Command-K)"
let header = NSStackView(views: [notePicker, create, image]) actionsButton.menu = noteMenu
header.spacing = 8 let pill = ActionPill(primaryButton: createButton, actionsButton: actionsButton, primaryCommand: "n")
notePicker.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let footer = NSStackView(views: [status, NSView(), pill])
footer.orientation = .horizontal
footer.alignment = .centerY
footer.spacing = 12
editor.delegate = self editor.delegate = self
editor.isRichText = true editor.isRichText = true
@@ -70,26 +74,24 @@ final class NotesController: NSViewController, NSTextViewDelegate {
status.textColor = SunsetPalette.secondaryText status.textColor = SunsetPalette.secondaryText
status.setAccessibilityLabel("Notes save status") status.setAccessibilityLabel("Notes save status")
status.lineBreakMode = .byTruncatingTail status.lineBreakMode = .byTruncatingTail
for control in [header, scroll, status] { status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
for control in [scroll, footer] {
control.translatesAutoresizingMaskIntoConstraints = false control.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(control) view.addSubview(control)
} }
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
header.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), scroll.topAnchor.constraint(equalTo: view.topAnchor),
header.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
header.topAnchor.constraint(equalTo: view.topAnchor), scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
notePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8),
scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 10), footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
scroll.leadingAnchor.constraint(equalTo: header.leadingAnchor), footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
scroll.trailingAnchor.constraint(equalTo: header.trailingAnchor), footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
scroll.bottomAnchor.constraint(equalTo: status.topAnchor, constant: -8), footer.heightAnchor.constraint(equalToConstant: 36),
status.leadingAnchor.constraint(equalTo: header.leadingAnchor),
status.trailingAnchor.constraint(equalTo: header.trailingAnchor),
status.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -12),
]) ])
guard let store else { guard let store else {
editor.isEditable = false editor.isEditable = false
for control in [notePicker, create, image] { control.isEnabled = false } for control in [createButton, actionsButton] { control.isEnabled = false }
showError(loadError!) showError(loadError!)
return return
} }
@@ -117,8 +119,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
do { do {
try store.update(id: selectedID, contents: editor.attributedString()) try store.update(id: selectedID, contents: editor.attributedString())
isDirty = false isDirty = false
updatePicker() updateActions()
status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image" status.stringValue = "Saved locally · Markdown · Paste or drop images"
status.textColor = SunsetPalette.secondaryText status.textColor = SunsetPalette.secondaryText
return true return true
} catch { } catch {
@@ -138,9 +140,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
catch { showError(error) } catch { showError(error) }
} }
@objc private func switchNote() { @objc private func switchNote(_ sender: NSMenuItem) {
guard let value = notePicker.selectedItem?.representedObject as? UUID else { return } guard let value = sender.representedObject as? UUID, save() else { return }
guard save() else { updatePicker(); return }
select(value) select(value)
focusEditor() focusEditor()
} }
@@ -154,17 +155,18 @@ final class NotesController: NSViewController, NSTextViewDelegate {
editor.isEditable = true editor.isEditable = true
editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0))
MarkdownHighlighting.apply(to: editor) MarkdownHighlighting.apply(to: editor)
updatePicker() updateActions()
status.stringValue = "Saved locally · Markdown · Paste images or use Insert Image" status.stringValue = "Saved locally · Markdown · Paste or drop images"
status.textColor = SunsetPalette.secondaryText status.textColor = SunsetPalette.secondaryText
} }
private func updatePicker() { private func updateActions() {
notePicker.removeAllItems() noteMenu.removeAllItems()
for note in store?.notes ?? [] { for note in store?.notes ?? [] {
notePicker.menu?.addItem(withTitle: note.title, action: nil, keyEquivalent: "") let item = noteMenu.addItem(withTitle: note.title, action: #selector(switchNote(_:)), keyEquivalent: "")
notePicker.lastItem?.representedObject = note.id item.target = self
if note.id == selectedID { notePicker.select(notePicker.lastItem) } item.representedObject = note.id
item.state = note.id == selectedID ? .on : .off
} }
} }
@@ -174,30 +176,9 @@ final class NotesController: NSViewController, NSTextViewDelegate {
status.textColor = .systemRed status.textColor = .systemRed
} }
@objc private func insertImage() { @objc private func showActions() {
guard let window = view.window, editor.isEditable else { return } noteMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: actionsButton.bounds.maxY + 4), in: actionsButton)
let panel = NSOpenPanel() focusEditor()
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())
} }
} }
-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) window.makeFirstResponder(output)
} else if destination == "notes-editor" { } 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) window.makeFirstResponder(editor)
} }
#expect(window.isVisible) #expect(window.isVisible)
+33 -18
View File
@@ -19,40 +19,55 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
defer { window.orderOut(nil) } defer { window.orderOut(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let views = descendants(controller.view) let views = descendants(controller.view)
let editor = try #require(views.compactMap { $0 as? NotesTextView }.first) let editor = try #require(views.compactMap { $0 as? NSTextView }.first)
let picker = try #require(views.compactMap { $0 as? NSPopUpButton }.first) let actions = try #require(views.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Choose a note" })
let create = try #require(views.compactMap { $0 as? NSButton }.first { $0.title == "New Note" }) let menu = try #require(actions.menu)
let firstID = try #require(picker.selectedItem?.representedObject as? UUID) let firstID = try #require(menu.items.first { $0.state == .on }?.representedObject as? UUID)
// Empty notes have duplicate titles; both must remain individually selectable. // Empty notes have duplicate titles; both must remain individually selectable.
create.performClick(nil) window.makeFirstResponder(editor)
#expect(picker.numberOfItems == 2) let newNoteKey = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero,
let secondID = try #require(picker.selectedItem?.representedObject as? UUID) 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) #expect(secondID != firstID)
editor.insertText("# Second 🦊\n**bold** and `code`", replacementRange: editor.selectedRange()) editor.insertText("# Second 🦊\n**bold** and `code`", replacementRange: editor.selectedRange())
#expect((editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)?.pointSize == 17) #expect((editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont)?.pointSize == 17)
let codeRange = (editor.string as NSString).range(of: "`code`") let codeRange = (editor.string as NSString).range(of: "`code`")
let codeFont = try #require(editor.textStorage?.attribute(.font, at: codeRange.location, effectiveRange: nil) as? NSFont) let codeFont = try #require(editor.textStorage?.attribute(.font, at: codeRange.location, effectiveRange: nil) as? NSFont)
#expect(codeFont.isFixedPitch) #expect(codeFont.isFixedPitch)
let firstIndex = try #require(picker.itemArray.firstIndex { $0.representedObject as? UUID == firstID }) menu.performActionForItem(at: try #require(menu.items.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.string.isEmpty)
#expect(editor.undoManager?.canUndo != true) #expect(editor.undoManager?.canUndo != true)
editor.insertText("First note", replacementRange: editor.selectedRange()) 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)) 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) bitmap.setColor(.red, atX: 0, y: 0)
let imageURL = directory.appendingPathComponent("image.png") let imageURL = directory.appendingPathComponent("image.png")
try #require(bitmap.representation(using: .png, properties: [:])).write(to: imageURL) let png = try #require(bitmap.representation(using: .png, properties: [:]))
try editor.insertImage(at: imageURL) 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()) #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 restored = try NotesStore(directory: directory)
let first = try #require(restored.notes.first { $0.id == firstID }) 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: 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`") #expect(restored.notes.first { $0.id == secondID }?.contents.string == "# Second 🦊\n**bold** and `code`")
let reopened = NotesController(defaults: defaults, directory: directory) let reopened = NotesController(defaults: defaults, directory: directory)
let reopenedViews = descendants(reopened.view) 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) #expect(reopenedEditor.string == first.contents.string)
// Removing Markdown must also remove its previous visual style without changing the source. // 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)) reopenedEditor.insertText("Plain text", replacementRange: NSRange(location: 0, length: reopenedEditor.string.utf16.count))
@@ -68,11 +83,11 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
reopened.goBack() reopened.goBack()
#expect(!closed) #expect(!closed)
#expect(!reopened.save()) #expect(!reopened.save())
let reopenedPicker = try #require(reopenedViews.compactMap { $0 as? NSPopUpButton }.first) let reopenedActions = try #require(reopenedViews.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Choose a note" })
reopenedPicker.selectItem(at: try #require(reopenedPicker.itemArray.firstIndex { $0.representedObject as? UUID == secondID })) let reopenedMenu = try #require(reopenedActions.menu)
_ = NSApp.sendAction(try #require(reopenedPicker.action), to: reopenedPicker.target, from: reopenedPicker) reopenedMenu.performActionForItem(at: try #require(reopenedMenu.items.firstIndex { $0.representedObject as? UUID == secondID }))
#expect(reopenedEditor.string == "Unsaved") #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.removeItem(at: directory)
try FileManager.default.moveItem(at: backup, to: directory) try FileManager.default.moveItem(at: backup, to: directory)
#expect(reopened.save()) #expect(reopened.save())