diff --git a/README.md b/README.md index 38e6fae..092b34b 100644 --- a/README.md +++ b/README.md @@ -54,18 +54,19 @@ The launcher, emoji picker, notes, and translation tool open immediately without | Open a result or copy a calculated answer | Return or double-click | | Open actions for the selected entry | Command-K | | Open one of the first nine results | Hold Command until numbers appear, then press 1–9 | -| Close the open action/provider picker, otherwise dismiss the launcher | Escape | +| Close the open actions, provider, language, or note picker, otherwise dismiss the launcher | Escape | | Dismiss the launcher, including the current Command and pickers | Command-W | -| Open the translation provider picker | Command-P (in Translate) | +| Browse notes / open the translation provider picker | Command-P (in Notes / Translate) | +| Create a note | Command-N (in Notes, including while a popup is open) | | Go back without dismissing | Back chevron | | Open Settings | Command-comma | | Quit | Command-Q | Right-click the launcher background for Settings and Quit. Actions include favorites, resetting usage ranking, revealing applications in Finder, quitting them, and uninstalling them. -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 selection 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, 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 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 **Browse Notes** with Command-P 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,13 +135,15 @@ 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. 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. +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 **Browse Notes** (Command-P) and **Actions** (Command-K). Browse Notes opens Conjure’s styled, scrollable searchable picker, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its picker title; duplicate titles remain independently selectable. + +Actions opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P) and **New Note** (Command-N). Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its footer pill is covered. Escape, Back, or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed. 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. -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. +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 when no popup is open. Command-W dismisses Conjure, including any open popup; leaving Notes or switching destinations removes its popup completely. ## Translation diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index c54102b..351f929 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -33,17 +33,26 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat var selectProvider: ((Bool) -> Void)? private var currentProviderEntry: Int? var selectLanguage: ((String) -> Void)? - private struct LanguageChoice { - let code: String - let name: String + var selectNote: ((UUID) -> Void)? + var browseNotes: (() -> Void)? + var newNote: (() -> Void)? + private enum ChoiceID: Equatable { + case language(String) + case note(UUID) + } + private struct SelectionChoice { + let id: ChoiceID + let title: String let searchText: FuzzySearchText } - private var languages: [LanguageChoice]? - private var matchingLanguages: [LanguageChoice] = [] - private var currentLanguage: String? - private var languageTable: NSTableView? - private var languageScroll: NSScrollView? - private var languageViewportHeight: NSLayoutConstraint? + private var choices: [SelectionChoice]? + private var matchingChoices: [SelectionChoice] = [] + private var currentChoice: ChoiceID? + private var selectionTable: NSTableView? + private var selectionScroll: NSScrollView? + private var selectionViewportHeight: NSLayoutConstraint? + private var selectionAccessibilityLabel = "" + private var currentChoiceAccessibilityLabel = "" var closed: (() -> Void)? var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing } @@ -58,9 +67,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat empty = NSTextField(labelWithString: "No matching actions") applicationLabel = NSTextField(labelWithString: "") rowTopConstraints = [] - languageTable = nil - languageScroll = nil - languageViewportHeight = nil + selectionTable = nil + selectionScroll = nil + selectionViewportHeight = nil entries = [ ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text), ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text), @@ -73,7 +82,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat ActionEntry(title: "Change target language…", symbol: "character.bubble", color: SunsetPalette.text), ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: SunsetPalette.text), ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: SunsetPalette.text), - ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text) + ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text), + ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"), + ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N") ] view = ActionsPanelView() view.wantsLayer = true @@ -108,6 +119,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat search.sendsSearchStringImmediately = true search.setAccessibilityLabel("Search application actions") search.closeActions = { [weak self] in self?.close() } + search.commandKey = { [weak self] key in self?.handleCommandKey(key) ?? false } empty.textColor = SunsetPalette.secondaryText empty.font = .systemFont(ofSize: 14) empty.isHidden = true @@ -156,11 +168,11 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat search.heightAnchor.constraint(equalToConstant: 24) ]) content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor - if languages != nil { + if choices != nil { height.isActive = false let table = NSTableView() table.style = .plain - table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Language"))) + table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Selection"))) table.headerView = nil table.backgroundColor = .clear table.selectionHighlightStyle = .none @@ -172,8 +184,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat table.dataSource = self table.delegate = self table.target = self - table.action = #selector(chooseLanguageRow) - table.setAccessibilityLabel("Translation languages") + table.action = #selector(chooseSelectionRow) + table.setAccessibilityLabel(selectionAccessibilityLabel) let scroll = NSScrollView() scroll.documentView = table scroll.hasVerticalScroller = true @@ -189,9 +201,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat scroll.bottomAnchor.constraint(equalTo: search.topAnchor, constant: -8), viewportHeight, ]) - languageTable = table - languageScroll = scroll - languageViewportHeight = viewportHeight + selectionTable = table + selectionScroll = scroll + selectionViewportHeight = viewportHeight } updateSelection() } @@ -223,22 +235,42 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat func showLanguages(_ choices: [(code: String, name: String)], selected: String, source: Bool, relativeTo anchor: NSView) { - currentLanguage = selected + currentChoice = .language(selected) + selectionAccessibilityLabel = "Translation languages" + currentChoiceAccessibilityLabel = "Current language" show(title: source ? "Source language" : "Target language", relativeTo: anchor, available: [], searchAccessibilityLabel: source ? "Search source languages" : "Search target languages", - languageChoices: choices.map { LanguageChoice(code: $0.code, name: $0.name, searchText: FuzzySearchText($0.name)) }) + selectionChoices: choices.map { + SelectionChoice(id: .language($0.code), title: $0.name, searchText: FuzzySearchText($0.name)) + }, selectionName: "languages") + } + + func showNotes(_ notes: [(id: UUID, title: String)], selected: UUID?, relativeTo anchor: NSView) { + currentChoice = selected.map { .note($0) } + selectionAccessibilityLabel = "Notes" + currentChoiceAccessibilityLabel = "Current note" + show(title: "Browse Notes", relativeTo: anchor, available: [], + searchAccessibilityLabel: "Search notes", + selectionChoices: notes.map { + SelectionChoice(id: .note($0.id), title: $0.title, searchText: FuzzySearchText($0.title)) + }, selectionName: "notes") + } + + func showNotesActions(relativeTo anchor: NSView) { + show(title: "Notes", relativeTo: anchor, available: [12, 13], + searchAccessibilityLabel: "Search notes actions") } private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false, searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false, - languageChoices: [LanguageChoice]? = nil) { + selectionChoices: [SelectionChoice]? = nil, selectionName: String = "") { if viewIfLoaded?.superview != nil { if isShown, availableEntries == available, applicationLabel.stringValue == title { close(); return } close(animated: false) } guard let window = anchor.window, let host = window.contentView else { return } let pill = anchor.superview ?? anchor - languages = languageChoices + choices = selectionChoices // Reuse behavior, not native drawing state from a differently sized popup. if isViewLoaded { preparePresentation() } else { _ = view } applicationLabel.stringValue = title @@ -248,7 +280,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat entries[0].button.setAccessibilityLabel(openTitle) entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil) search.setAccessibilityLabel(searchAccessibilityLabel) - empty.stringValue = languages == nil ? "No matching actions" : "No matching languages" + empty.stringValue = choices == nil ? "No matching actions" : "No matching \(selectionName)" let favorite = entries[1] let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite" favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [ @@ -260,7 +292,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat entries[index].icon.isHidden = currentProviderEntry != index } search.placeholderAttributedString = NSAttributedString( - string: languages != nil ? "Search for languages…" : belowAnchor ? "Search for providers…" : "Search for actions…", + string: choices != nil ? "Search for \(selectionName)…" : belowAnchor ? "Search for providers…" : "Search for actions…", attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize)]) search.stringValue = "" @@ -287,7 +319,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat coveredPill?.setAccessibilityHidden(true) host.layoutSubtreeIfNeeded() background.needsDisplay = true - if let selectedEntry { languageTable?.scrollRowToVisible(selectedEntry) } + if let selectedEntry { selectionTable?.scrollRowToVisible(selectedEntry) } NSAnimationContext.runAnimationGroup { context in context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16 view.animator().alphaValue = 1 @@ -345,16 +377,16 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat func controlTextDidChange(_ notification: Notification) { let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) - if let languages, let table = languageTable { - matchingLanguages = FuzzySearchText.results(for: query, in: languages, text: \.searchText) { - $0.name.localizedStandardCompare($1.name) == .orderedAscending + if let choices, let table = selectionTable { + matchingChoices = FuzzySearchText.results(for: query, in: choices, text: \.searchText) { + $0.title.localizedStandardCompare($1.title) == .orderedAscending } - let selection = (query.isEmpty ? matchingLanguages.firstIndex(where: { $0.code == currentLanguage }) : nil) - ?? (matchingLanguages.isEmpty ? nil : 0) + let selection = (query.isEmpty ? matchingChoices.firstIndex(where: { $0.id == currentChoice }) : nil) + ?? (matchingChoices.isEmpty ? nil : 0) for entry in entries { entry.view.isHidden = true } - languageViewportHeight?.constant = CGFloat(max(min(matchingLanguages.count, 6), 1)) * table.rowHeight - empty.isHidden = !matchingLanguages.isEmpty - languageScroll?.isHidden = matchingLanguages.isEmpty + selectionViewportHeight?.constant = CGFloat(max(min(matchingChoices.count, 6), 1)) * table.rowHeight + empty.isHidden = !matchingChoices.isEmpty + selectionScroll?.isHidden = matchingChoices.isEmpty view.superview?.layoutSubtreeIfNeeded() table.reloadData() selectedEntry = selection @@ -380,9 +412,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat } func moveSelection(by offset: Int) { - if let table = languageTable { - guard !matchingLanguages.isEmpty else { return } - let next = min(max((selectedEntry ?? 0) + offset, 0), matchingLanguages.count - 1) + if let table = selectionTable { + guard !matchingChoices.isEmpty else { return } + let next = min(max((selectedEntry ?? 0) + offset, 0), matchingChoices.count - 1) table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false) table.scrollRowToVisible(next) return @@ -394,10 +426,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat } private func updateSelection() { - if let table = languageTable { + if let table = selectionTable { let visible = table.rows(in: table.visibleRect) for row in visible.location.. Int { matchingLanguages.count } + private func handleCommandKey(_ key: String) -> Bool { + guard isShown, availableEntries == [12, 13] else { return false } + switch key { + case "p": close(); browseNotes?() + case "n": close(); newNote?() + default: return false + } + return true + } + + func numberOfRows(in tableView: NSTableView) -> Int { matchingChoices.count } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { - let identifier = NSUserInterfaceItemIdentifier("LanguageCell") - let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? LanguageCell ?? LanguageCell() + let identifier = NSUserInterfaceItemIdentifier("SelectionCell") + let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? SelectionCell ?? SelectionCell() cell.identifier = identifier - let choice = matchingLanguages[row] - cell.entry.button.title = choice.name - cell.entry.button.setAccessibilityLabel(choice.name) - cell.entry.button.setAccessibilityValue(choice.code == currentLanguage ? "Current language" : "") + let choice = matchingChoices[row] + cell.entry.button.title = choice.title + cell.entry.button.setAccessibilityLabel(choice.title) + cell.entry.button.setAccessibilityValue(choice.id == currentChoice ? currentChoiceAccessibilityLabel : "") cell.entry.button.target = self cell.entry.button.action = #selector(chooseButton(_:)) cell.entry.button.tag = row - cell.entry.icon.isHidden = choice.code != currentLanguage + cell.entry.icon.isHidden = choice.id != currentChoice cell.entry.view.isSelected = selectedEntry == row cell.entry.returnKey.isHidden = selectedEntry != row return cell } func tableViewSelectionDidChange(_ notification: Notification) { - guard let table = languageTable else { return } + guard let table = selectionTable else { return } selectedEntry = table.selectedRow >= 0 ? table.selectedRow : nil updateSelection() } - @objc private func chooseLanguageRow() { + @objc private func chooseSelectionRow() { if let selectedEntry { chooseAction(at: selectedEntry) } } @@ -468,11 +510,14 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat private func chooseAction(at index: Int) { guard isShown else { return } - if languages != nil { - guard matchingLanguages.indices.contains(index) else { return } - let code = matchingLanguages[index].code + if choices != nil { + guard matchingChoices.indices.contains(index) else { return } + let id = matchingChoices[index].id close() - selectLanguage?(code) + switch id { + case .language(let code): selectLanguage?(code) + case .note(let id): selectNote?(id) + } return } guard visibleEntries.contains(index) else { return } @@ -490,6 +535,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat case 9: swapLanguages?() case 10: selectProvider?(false) case 11: selectProvider?(true) + case 12: browseNotes?() + case 13: newNote?() default: break } } @@ -500,9 +547,12 @@ private struct ActionEntry { let view = LauncherRowView() let button: NSButton let icon: IconView - let returnKey = ShortcutIndicator("↵") + let returnKey: ShortcutIndicator + let hasCommandKey: Bool - init(title: String, symbol: String, color: NSColor) { + init(title: String, symbol: String, color: NSColor, commandKey: String? = nil) { + hasCommandKey = commandKey != nil + returnKey = commandKey.map { ShortcutIndicator("⌘", $0) } ?? ShortcutIndicator("↵") let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium) icon = IconView(font: font) button = NSButton(title: title, target: nil, action: nil) @@ -535,7 +585,7 @@ private struct ActionEntry { } @MainActor -private final class LanguageCell: NSView { +private final class SelectionCell: NSView { let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text) init() { @@ -550,7 +600,7 @@ private final class LanguageCell: NSView { ]) } - required init?(coder: NSCoder) { fatalError("Conjure creates language rows programmatically") } + required init?(coder: NSCoder) { fatalError("Conjure creates selection rows programmatically") } } @MainActor @@ -565,12 +615,16 @@ private final class ActionsPanelView: NSView { @MainActor private final class ActionsSearchField: NSSearchField { var closeActions: (() -> Void)? + var commandKey: ((String) -> Bool)? override func performKeyEquivalent(with event: NSEvent) -> Bool { if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command, - event.charactersIgnoringModifiers?.lowercased() == "k" { - closeActions?() - return true + let key = event.charactersIgnoringModifiers?.lowercased() { + if key == "k" { + closeActions?() + return true + } + if commandKey?(key) == true { return true } } return super.performKeyEquivalent(with: event) } diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index fedc47b..8cfc1b5 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -98,8 +98,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe else { self.moveSelection(by: offset) } } window.commandNavigation = { [weak self] key in - self?.translation?.handleControlKey(key) ?? self?.emojiPicker?.handleControlKey(key) ?? false + self?.notes?.handleControlKey(key) ?? self?.translation?.handleControlKey(key) + ?? self?.emojiPicker?.handleControlKey(key) ?? false } + window.commandShortcut = { [weak self] key in self?.notes?.handleCommandKey(key) ?? false } window.usesTextNavigation = { [weak self] in self?.notes != nil } window.dismiss = { [weak self] in self?.dismiss() } window.escape = { [weak self] in self?.handleEscape() } @@ -476,7 +478,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe private func handleEscape() { let selector = #selector(NSResponder.cancelOperation(_:)) - if actions.handle(selector) || translation?.handle(selector) == true || emojiPicker?.handle(selector) == true { return } + if actions.handle(selector) || notes?.handle(selector) == true + || translation?.handle(selector) == true || emojiPicker?.handle(selector) == true { return } dismiss() } @@ -485,6 +488,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe handleEscape() return true } + if let notes { return notes.handle(commandSelector) } if let emojiPicker { return emojiPicker.handle(commandSelector) } if let translation { return translation.handle(commandSelector) } switch commandSelector { @@ -807,8 +811,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe @discardableResult private func closeCommand() -> Bool { - guard saveNotes() else { return false } - notes?.closed?() + guard notes?.close() != false else { return false } translation?.close() emojiPicker?.close() return true @@ -1200,6 +1203,7 @@ final class LauncherPanel: NSPanel { var numberHintsChanged: (() -> Void)? var launchNumber: ((Int) -> Void)? var commandNavigation: ((String) -> Bool)? + var commandShortcut: ((String) -> Bool)? var usesTextNavigation: (() -> Bool)? private var numberShortcuts = CommandNumberShortcuts() private var commandHoldTask: Task? @@ -1304,18 +1308,20 @@ final class LauncherPanel: NSPanel { } resetNumberHints(modifierFlags: event.modifierFlags) } - if event.type == .keyDown, attachedSheet == nil, usesTextNavigation?() != true, + if event.type == .keyDown, attachedSheet == nil, event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control { if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return } - switch event.charactersIgnoringModifiers?.lowercased() { - case "j": - navigateSelection?(1) - return - case "k": - navigateSelection?(-1) - return - default: - break + if usesTextNavigation?() != true { + switch event.charactersIgnoringModifiers?.lowercased() { + case "j": + navigateSelection?(1) + return + case "k": + navigateSelection?(-1) + return + default: + break + } } } super.sendEvent(event) @@ -1324,6 +1330,15 @@ final class LauncherPanel: NSPanel { } } + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if attachedSheet == nil, + event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command, + commandShortcut?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { + return true + } + return super.performKeyEquivalent(with: event) + } + override func cancelOperation(_ sender: Any?) { if let escape { escape() } else { dismiss?() } } diff --git a/Sources/Conjure/NotesController.swift b/Sources/Conjure/NotesController.swift index a886278..9f0cbce 100644 --- a/Sources/Conjure/NotesController.swift +++ b/Sources/Conjure/NotesController.swift @@ -4,9 +4,9 @@ import Highlighter @MainActor final class NotesController: NSViewController, NSTextViewDelegate { - private let createButton = NSButton(title: "New Note", target: nil, action: nil) + private let browseButton = NSButton(title: "Browse Notes", target: nil, action: nil) private let actionsButton = NSButton(title: "Actions", target: nil, action: nil) - private let noteMenu = NSMenu(title: "Notes") + private let actions = ApplicationActionsController() private let editor = NSTextView() private let status = NSTextField(labelWithString: "") private let defaults: UserDefaults @@ -29,16 +29,22 @@ final class NotesController: NSViewController, NSTextViewDelegate { required init?(coder: NSCoder) { fatalError("Conjure creates Notes programmatically") } override func loadView() { - view = NSView() - createButton.target = self - createButton.action = #selector(newNote) - createButton.toolTip = "Create a note (Command-N)" + let content = NotesView() + content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false } + view = content + browseButton.target = self + browseButton.action = #selector(browseNotes) + browseButton.setAccessibilityLabel("Browse notes") + browseButton.toolTip = "Browse notes (Command-P)" 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") + actionsButton.setAccessibilityLabel("Notes actions") + actionsButton.toolTip = "Notes actions (Command-K)" + actions.closed = { [weak self] in self?.focusEditor() } + actions.selectNote = { [weak self] in self?.switchNote($0) } + actions.newNote = { [weak self] in self?.newNote() } + actions.browseNotes = { [weak self] in self?.browseNotes() } + let pill = ActionPill(primaryButton: browseButton, actionsButton: actionsButton, primaryCommand: "p") let footer = NSStackView(views: [status, NSView(), pill]) footer.orientation = .horizontal footer.alignment = .centerY @@ -91,7 +97,7 @@ final class NotesController: NSViewController, NSTextViewDelegate { ]) guard let store else { editor.isEditable = false - for control in [createButton, actionsButton] { control.isEnabled = false } + for control in [browseButton, actionsButton] { control.isEnabled = false } showError(loadError!) return } @@ -119,7 +125,6 @@ final class NotesController: NSViewController, NSTextViewDelegate { do { try store.update(id: selectedID, contents: editor.attributedString()) isDirty = false - updateActions() status.stringValue = "Saved locally · Markdown · Paste or drop images" status.textColor = SunsetPalette.secondaryText return true @@ -129,20 +134,50 @@ final class NotesController: NSViewController, NSTextViewDelegate { } } - func goBack() { - guard save() else { return } + func handle(_ selector: Selector) -> Bool { + actions.isShown && actions.handle(selector) + } + + func handleControlKey(_ key: String) -> Bool { + guard actions.isShown, key == "j" || key == "k" else { return false } + actions.moveSelection(by: key == "j" ? 1 : -1) + return true + } + + func handleCommandKey(_ key: String) -> Bool { + guard store != nil else { return false } + switch key { + case "p": browseNotes() + case "k": showActions() + case "n": newNote() + default: return false + } + return true + } + + @discardableResult + func close() -> Bool { + guard save() else { return false } + actions.close(animated: false) closed?() + return true + } + + func goBack() { + if actions.isShown { actions.close() } + else { _ = close() } } @objc private func newNote() { + actions.close(animated: false) guard save(), let store else { return } do { select(try store.create()); focusEditor() } catch { showError(error) } } - @objc private func switchNote(_ sender: NSMenuItem) { - guard let value = sender.representedObject as? UUID, save() else { return } - select(value) + private func switchNote(_ id: UUID) { + guard save() else { return } + select(id) focusEditor() } @@ -155,20 +190,10 @@ final class NotesController: NSViewController, NSTextViewDelegate { editor.isEditable = true editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) MarkdownHighlighting.apply(to: editor) - updateActions() status.stringValue = "Saved locally · Markdown · Paste or drop images" status.textColor = SunsetPalette.secondaryText } - private func updateActions() { - noteMenu.removeAllItems() - for note in store?.notes ?? [] { - 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 - } - } private func showError(_ error: Error) { status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)" @@ -176,9 +201,28 @@ final class NotesController: NSViewController, NSTextViewDelegate { status.textColor = .systemRed } - @objc private func showActions() { - noteMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: actionsButton.bounds.maxY + 4), in: actionsButton) - focusEditor() + @objc func browseNotes() { + guard let store else { return } + actions.showNotes(store.notes.map { (id: $0.id, title: $0.title) }, + selected: selectedID, relativeTo: browseButton) + } + + @objc func showActions() { + guard store != nil else { return } + actions.showNotesActions(relativeTo: actionsButton) + } +} + +@MainActor +private final class NotesView: NSView { + var commandShortcut: ((String) -> Bool)? + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command, + commandShortcut?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { + return true + } + return super.performKeyEquivalent(with: event) } } diff --git a/TODO.md b/TODO.md index e69de29..7a4f8e1 100644 --- a/TODO.md +++ b/TODO.md @@ -0,0 +1,2 @@ +- [ ] the guidelines for centering the application when dragged are blurry on some external monitors. + - track down the cause and fix it. \ No newline at end of file diff --git a/Tests/ConjureKitTests/EscapeDismissalTests.swift b/Tests/ConjureKitTests/EscapeDismissalTests.swift index 91f5971..fe93898 100644 --- a/Tests/ConjureKitTests/EscapeDismissalTests.swift +++ b/Tests/ConjureKitTests/EscapeDismissalTests.swift @@ -3,7 +3,7 @@ import ConjureKit import Testing @testable import Conjure -@Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "translate-actions", "translate-providers", "notes-editor"]) +@Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "translate-actions", "translate-providers", "notes-editor", "notes-browse", "notes-actions"]) @MainActor func escapeClosesPickerBeforeDismissingLauncher(_ destination: String) throws { _ = NSApplication.shared let suite = "ConjureEscape.\(UUID().uuidString)" @@ -58,6 +58,14 @@ import Testing $0.accessibilityLabel() == "Japanese translation" }) window.makeFirstResponder(output) + } else if destination == "notes-browse" || destination == "notes-actions" { + let shortcut = try #require(NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: .command, timestamp: 1, + windowNumber: window.windowNumber, context: nil, + characters: destination == "notes-browse" ? "p" : "k", + charactersIgnoringModifiers: destination == "notes-browse" ? "p" : "k", + isARepeat: false, keyCode: destination == "notes-browse" ? 35 : 40)) + #expect(window.performKeyEquivalent(with: shortcut)) } else if destination == "notes-editor" { let editor = try #require(descendants(content).compactMap { $0 as? NSTextView }.first { $0.accessibilityLabel() == "Note editor" @@ -70,11 +78,21 @@ import Testing windowNumber: window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)) window.sendEvent(escape) - if destination.hasSuffix("-actions") || destination == "translate-providers" { + if destination.hasSuffix("-actions") || destination == "translate-providers" || destination == "notes-browse" { #expect(window.isVisible) - #expect(window.firstResponder === search.currentEditor()) + if destination.hasPrefix("notes") { + let editor = try #require(descendants(content).compactMap { $0 as? NSTextView }.first { + $0.accessibilityLabel() == "Note editor" + }) + #expect(window.firstResponder === editor) + } else { + #expect(window.firstResponder === search.currentEditor()) + } window.sendEvent(escape) } #expect(!window.isVisible) #expect(!descendants(content).contains { $0 is NSCollectionView }) + #expect(!descendants(content).compactMap { $0 as? NSSearchField }.contains { + $0.accessibilityLabel() == "Search notes" || $0.accessibilityLabel() == "Search notes actions" + }) } diff --git a/Tests/ConjureKitTests/LanguagePickerTests.swift b/Tests/ConjureKitTests/LanguagePickerTests.swift index c7bcf47..848a41e 100644 --- a/Tests/ConjureKitTests/LanguagePickerTests.swift +++ b/Tests/ConjureKitTests/LanguagePickerTests.swift @@ -118,4 +118,91 @@ struct LanguagePickerTests { } } } + + @Test @MainActor + func notesShareScrollableFuzzySelectionAndKeepDuplicateTitlesDistinct() throws { + _ = NSApplication.shared + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 474), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let host = try #require(window.contentView) + let pill = NSView(frame: NSRect(x: 500, y: 8, width: 230, height: 36)) + let anchor = NSButton(frame: pill.bounds) + pill.addSubview(anchor) + host.addSubview(pill) + let popup = ApplicationActionsController() + defer { popup.close(animated: false); window.orderOut(nil) } + var notes = (0..<20).map { (id: UUID(), title: String(format: "Note %02d", $0)) } + notes[18].title = "Shared Plan" + notes[19].title = "Shared Plan" + var selected: UUID? + popup.selectNote = { selected = $0 } + popup.showNotes(notes, selected: notes[19].id, relativeTo: anchor) + host.layoutSubtreeIfNeeded() + let table = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first) + #expect(table.accessibilityLabel() == "Notes") + #expect(table.numberOfRows == 20) + #expect(table.selectedRow == 19) + #expect(NSLocationInRange(table.selectedRow, table.rows(in: table.visibleRect))) + #expect(table.visibleRect.height < table.bounds.height) + let currentCell = try #require(table.view(atColumn: 0, row: 19, makeIfNecessary: true)) + let current = try #require(descendants(currentCell).compactMap { $0 as? NSButton }.first) + #expect(current.accessibilityValue() as? String == "Current note") + popup.moveSelection(by: -1) + #expect(popup.handle(#selector(NSResponder.insertNewline(_:)))) + #expect(selected == notes[18].id) + popup.close(animated: false) + + popup.showNotes(notes, selected: notes[19].id, relativeTo: anchor) + let search = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first) + #expect(search.accessibilityLabel() == "Search notes") + search.stringValue = "shpl" + search.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: search)) + let filteredTable = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first) + #expect(filteredTable.numberOfRows == 2) + popup.moveSelection(by: 1) + #expect(popup.handle(#selector(NSResponder.insertNewline(_:)))) + #expect(selected == notes[19].id) + } + + @Test @MainActor + func notesActionsSearchAndCommandShortcutsUseTheSharedPopup() throws { + _ = NSApplication.shared + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 474), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let host = try #require(window.contentView) + let pill = NSView(frame: NSRect(x: 500, y: 8, width: 230, height: 36)) + let anchor = NSButton(frame: pill.bounds) + pill.addSubview(anchor) + host.addSubview(pill) + let popup = ApplicationActionsController() + defer { popup.close(animated: false); window.orderOut(nil) } + var browsed = 0 + var created = 0 + popup.browseNotes = { browsed += 1 } + popup.newNote = { created += 1 } + + popup.showNotesActions(relativeTo: anchor) + let search = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first) + #expect(search.accessibilityLabel() == "Search notes actions") + search.stringValue = "new" + search.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: search)) + #expect(popup.handle(#selector(NSResponder.insertNewline(_:)))) + #expect(created == 1) + popup.close(animated: false) + + for key in ["p", "n"] { + popup.showNotesActions(relativeTo: anchor) + let field = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first) + let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command, + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: key, + charactersIgnoringModifiers: key, isARepeat: false, keyCode: key == "p" ? 35 : 45)) + #expect(field.performKeyEquivalent(with: event)) + #expect(!popup.isShown) + popup.close(animated: false) + } + #expect(browsed == 1) + #expect(created == 2) + } } diff --git a/Tests/ConjureKitTests/NotesEditingTests.swift b/Tests/ConjureKitTests/NotesEditingTests.swift index a00082a..d72d8b7 100644 --- a/Tests/ConjureKitTests/NotesEditingTests.swift +++ b/Tests/ConjureKitTests/NotesEditingTests.swift @@ -16,27 +16,52 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws 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) } + defer { _ = controller.close(); 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? 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. + let browse = try #require(views.compactMap { $0 as? NSButton }.first { $0.title == "Browse Notes" }) + #expect(browse.keyEquivalent == "p") + #expect(!views.compactMap { $0 as? NSButton }.contains { $0.title == "New Note" }) + func selectedID() throws -> UUID { try #require(defaults.string(forKey: "selectedNote").flatMap(UUID.init(uuidString:))) } + func picker(_ tool: NotesController) throws -> NSSearchField { + try #require(descendants(tool.view).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == "Search notes" }) + } + func filter(_ field: NSSearchField, _ query: String) { + field.stringValue = query + field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field)) + field.window?.contentView?.layoutSubtreeIfNeeded() + } + let firstID = try selectedID() + // Empty notes have duplicate titles; UUID identity must survive the shared picker. 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) + let secondID = try selectedID() #expect(secondID != firstID) + for expectedID in [firstID, secondID] { + browse.performClick(nil) + _ = try picker(controller) + let table = try #require(descendants(controller.view).compactMap { $0 as? NSTableView }.first) + #expect(table.numberOfRows == 2) + #expect(controller.handleControlKey(table.selectedRow == 0 ? "j" : "k")) + #expect(controller.handle(#selector(NSResponder.insertNewline(_:)))) + #expect(try selectedID() == expectedID) + #expect(window.firstResponder === editor) + } + #expect(!controller.handleControlKey("j")) + #expect(!controller.handle(#selector(NSResponder.insertNewline(_:)))) 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) - menu.performActionForItem(at: try #require(menu.items.firstIndex { $0.representedObject as? UUID == firstID })) + browse.performClick(nil) + filter(try picker(controller), "nttld") + #expect(controller.handle(#selector(NSResponder.insertNewline(_:)))) + #expect(try selectedID() == firstID) #expect(editor.string.isEmpty) #expect(editor.undoManager?.canUndo != true) editor.insertText("First note", replacementRange: editor.selectedRange()) @@ -58,7 +83,7 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws 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) + #expect(try selectedID() == 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}\u{fffc}\n") @@ -66,6 +91,9 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws #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 reopenedWindow = NSWindow(contentRect: window.frame, styleMask: [.borderless], backing: .buffered, defer: false) + reopenedWindow.contentViewController = reopened + defer { _ = reopened.close(); reopenedWindow.orderOut(nil) } let reopenedViews = descendants(reopened.view) let reopenedEditor = try #require(reopenedViews.compactMap { $0 as? NSTextView }.first) #expect(reopenedEditor.string == first.contents.string) @@ -83,14 +111,122 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws reopened.goBack() #expect(!closed) #expect(!reopened.save()) - 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 })) + reopened.browseNotes() + filter(try picker(reopened), "scnd") + #expect(reopened.handle(#selector(NSResponder.insertNewline(_:)))) #expect(reopenedEditor.string == "Unsaved") - #expect(reopenedMenu.items.first { $0.state == .on }?.representedObject as? UUID == firstID) + #expect(try selectedID() == firstID) + reopened.browseNotes() + #expect(!reopened.close()) + #expect(!closed) + #expect(try picker(reopened).window === reopenedWindow) try FileManager.default.removeItem(at: directory) try FileManager.default.moveItem(at: backup, to: directory) #expect(reopened.save()) - reopened.goBack() + #expect(reopened.close()) #expect(closed) + #expect(!descendants(reopened.view).contains { ($0 as? NSSearchField)?.accessibilityLabel() == "Search notes" }) +} + +@Test @MainActor +func notesLauncherRoutesShortcutsAndTearsDownPickersWithoutTakingEditorNavigation() throws { + _ = NSApplication.shared + let suite = "NotesShortcuts.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite) + defer { + defaults.removePersistentDomain(forName: suite) + try? FileManager.default.removeItem(at: directory) + } + let store = try NotesStore(directory: directory) + let atlas = try store.create() + let journal = try store.create() + try store.update(id: atlas, contents: NSAttributedString(string: "Project Atlas")) + try store.update(id: journal, contents: NSAttributedString(string: "Weekend journal")) + defaults.set(atlas.uuidString, forKey: "selectedNote") + let launcher = LauncherWindowController( + history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }), + defaults: defaults, notesDirectory: directory) + let window = try #require(launcher.window) + defer { launcher.dismiss(restoringFocus: false) } + let content = try #require(window.contentView) + func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + func search(_ label: String) throws -> NSSearchField { + try #require(descendants(content).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == label }) + } + func key(_ text: String, code: UInt16, flags: NSEvent.ModifierFlags = []) throws -> NSEvent { + let characters = flags == .control && text == "k" ? "\u{b}" : text + return try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, + timestamp: 1, windowNumber: window.windowNumber, context: nil, characters: characters, + charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)) + } + func command(_ text: String, code: UInt16) throws { + #expect(window.performKeyEquivalent(with: try key(text, code: code, flags: .command))) + } + func filter(_ field: NSSearchField, _ text: String) { + field.stringValue = text + field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field)) + content.layoutSubtreeIfNeeded() + } + let launcherSearch = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first) + launcherSearch.stringValue = "notes" + launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: launcherSearch)) + #expect(launcher.control(launcherSearch, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))) + window.makeKeyAndOrderFront(nil) + let browse = try #require(descendants(content).compactMap { $0 as? NSButton }.first { $0.title == "Browse Notes" }) + let tool = try #require(browse.target as? NotesController) + let editor = try #require(descendants(content).compactMap { $0 as? NSTextView }.first { $0.accessibilityLabel() == "Note editor" }) + window.makeFirstResponder(editor) + try command("p", code: 35) + let picker = try search("Search notes") + let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first { $0.accessibilityLabel() == "Notes" }) + #expect(window.firstResponder === picker.currentEditor()) + #expect(browse.superview?.isAccessibilityHidden() == true) + #expect(tool.handle(#selector(NSResponder.moveUp(_:)))) + window.sendEvent(try key("j", code: 38, flags: .control)) + #expect(table.selectedRow == 1) + window.sendEvent(try key("k", code: 40, flags: .control)) + #expect(table.selectedRow == 0) + filter(picker, "wkndjrnl") + #expect(table.numberOfRows == 1) + #expect(tool.handle(#selector(NSResponder.insertNewline(_:)))) + #expect(defaults.string(forKey: "selectedNote") == journal.uuidString) + #expect(editor.string == "Weekend journal") + #expect(window.firstResponder === editor) + try command("p", code: 35) + _ = try search("Search notes") + try command("p", code: 35) + #expect(window.firstResponder === editor) + try command("k", code: 40) + filter(try search("Search notes actions"), "Browse Notes") + #expect(tool.handle(#selector(NSResponder.insertNewline(_:)))) + _ = try search("Search notes") + window.sendEvent(try key("\u{1b}", code: 53)) + #expect(window.isVisible && window.firstResponder === editor) + try command("k", code: 40) + filter(try search("Search notes actions"), "New Note") + #expect(tool.handle(#selector(NSResponder.insertNewline(_:)))) + #expect(editor.string.isEmpty) + let actionCreatedID = defaults.string(forKey: "selectedNote") + try command("n", code: 45) + #expect(defaults.string(forKey: "selectedNote") != actionCreatedID) + let editorCreatedID = defaults.string(forKey: "selectedNote") + try command("p", code: 35) + _ = try search("Search notes") + try command("n", code: 45) + #expect(defaults.string(forKey: "selectedNote") != editorCreatedID) + #expect(window.firstResponder === editor) + #expect(!tool.handle(#selector(NSResponder.moveDown(_:)))) + #expect(!tool.handleControlKey("k")) + editor.insertText("Navigation", replacementRange: editor.selectedRange()) + editor.setSelectedRange(NSRange(location: 0, length: 0)) + window.sendEvent(try key("k", code: 40, flags: .control)) + #expect(editor.string.isEmpty) + editor.insertText("Editable", replacementRange: editor.selectedRange()) + editor.keyDown(with: try key("\r", code: 36)) + #expect(editor.string == "Editable\n") + try command("p", code: 35) + #expect(tool.close()) + #expect(!descendants(content).compactMap { $0 as? NSSearchField }.contains { $0.accessibilityLabel() == "Search notes" }) + #expect(launcherSearch.isHidden == false) }