style: use shared notes browsing and actions panels

This commit is contained in:
Valdemar Lorenzen
2026-10-19 10:51:45 +02:00
parent 0b5d27752c
commit 9ce2997c19
8 changed files with 487 additions and 128 deletions
+9 -6
View File
@@ -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 a result or copy a calculated answer | Return or double-click |
| Open actions for the selected entry | Command-K | | Open actions for the selected entry | Command-K |
| Open one of the first nine results | Hold Command until numbers appear, then press 1–9 | | 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 | | 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 | | Go back without dismissing | Back chevron |
| Open Settings | Command-comma | | Open Settings | Command-comma |
| Quit | Command-Q | | 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. 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. 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 ## 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. 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.
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 ## Translation
@@ -33,17 +33,26 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
var selectProvider: ((Bool) -> Void)? var selectProvider: ((Bool) -> Void)?
private var currentProviderEntry: Int? private var currentProviderEntry: Int?
var selectLanguage: ((String) -> Void)? var selectLanguage: ((String) -> Void)?
private struct LanguageChoice { var selectNote: ((UUID) -> Void)?
let code: String var browseNotes: (() -> Void)?
let name: String 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 let searchText: FuzzySearchText
} }
private var languages: [LanguageChoice]? private var choices: [SelectionChoice]?
private var matchingLanguages: [LanguageChoice] = [] private var matchingChoices: [SelectionChoice] = []
private var currentLanguage: String? private var currentChoice: ChoiceID?
private var languageTable: NSTableView? private var selectionTable: NSTableView?
private var languageScroll: NSScrollView? private var selectionScroll: NSScrollView?
private var languageViewportHeight: NSLayoutConstraint? private var selectionViewportHeight: NSLayoutConstraint?
private var selectionAccessibilityLabel = ""
private var currentChoiceAccessibilityLabel = ""
var closed: (() -> Void)? var closed: (() -> Void)?
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing } var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
@@ -58,9 +67,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
empty = NSTextField(labelWithString: "No matching actions") empty = NSTextField(labelWithString: "No matching actions")
applicationLabel = NSTextField(labelWithString: "") applicationLabel = NSTextField(labelWithString: "")
rowTopConstraints = [] rowTopConstraints = []
languageTable = nil selectionTable = nil
languageScroll = nil selectionScroll = nil
languageViewportHeight = nil selectionViewportHeight = nil
entries = [ entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text), ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
ActionEntry(title: "Favorite", symbol: "star", 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: "Change target language…", symbol: "character.bubble", color: SunsetPalette.text),
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", 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: "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 = ActionsPanelView()
view.wantsLayer = true view.wantsLayer = true
@@ -108,6 +119,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
search.sendsSearchStringImmediately = true search.sendsSearchStringImmediately = true
search.setAccessibilityLabel("Search application actions") search.setAccessibilityLabel("Search application actions")
search.closeActions = { [weak self] in self?.close() } search.closeActions = { [weak self] in self?.close() }
search.commandKey = { [weak self] key in self?.handleCommandKey(key) ?? false }
empty.textColor = SunsetPalette.secondaryText empty.textColor = SunsetPalette.secondaryText
empty.font = .systemFont(ofSize: 14) empty.font = .systemFont(ofSize: 14)
empty.isHidden = true empty.isHidden = true
@@ -156,11 +168,11 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
search.heightAnchor.constraint(equalToConstant: 24) search.heightAnchor.constraint(equalToConstant: 24)
]) ])
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
if languages != nil { if choices != nil {
height.isActive = false height.isActive = false
let table = NSTableView() let table = NSTableView()
table.style = .plain table.style = .plain
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Language"))) table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Selection")))
table.headerView = nil table.headerView = nil
table.backgroundColor = .clear table.backgroundColor = .clear
table.selectionHighlightStyle = .none table.selectionHighlightStyle = .none
@@ -172,8 +184,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
table.dataSource = self table.dataSource = self
table.delegate = self table.delegate = self
table.target = self table.target = self
table.action = #selector(chooseLanguageRow) table.action = #selector(chooseSelectionRow)
table.setAccessibilityLabel("Translation languages") table.setAccessibilityLabel(selectionAccessibilityLabel)
let scroll = NSScrollView() let scroll = NSScrollView()
scroll.documentView = table scroll.documentView = table
scroll.hasVerticalScroller = true scroll.hasVerticalScroller = true
@@ -189,9 +201,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
scroll.bottomAnchor.constraint(equalTo: search.topAnchor, constant: -8), scroll.bottomAnchor.constraint(equalTo: search.topAnchor, constant: -8),
viewportHeight, viewportHeight,
]) ])
languageTable = table selectionTable = table
languageScroll = scroll selectionScroll = scroll
languageViewportHeight = viewportHeight selectionViewportHeight = viewportHeight
} }
updateSelection() updateSelection()
} }
@@ -223,22 +235,42 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func showLanguages(_ choices: [(code: String, name: String)], selected: String, func showLanguages(_ choices: [(code: String, name: String)], selected: String,
source: Bool, relativeTo anchor: NSView) { 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: [], show(title: source ? "Source language" : "Target language", relativeTo: anchor, available: [],
searchAccessibilityLabel: source ? "Search source languages" : "Search target languages", 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, private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false,
searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false, searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false,
languageChoices: [LanguageChoice]? = nil) { selectionChoices: [SelectionChoice]? = nil, selectionName: String = "") {
if viewIfLoaded?.superview != nil { if viewIfLoaded?.superview != nil {
if isShown, availableEntries == available, applicationLabel.stringValue == title { close(); return } if isShown, availableEntries == available, applicationLabel.stringValue == title { close(); return }
close(animated: false) close(animated: false)
} }
guard let window = anchor.window, let host = window.contentView else { return } guard let window = anchor.window, let host = window.contentView else { return }
let pill = anchor.superview ?? anchor let pill = anchor.superview ?? anchor
languages = languageChoices choices = selectionChoices
// Reuse behavior, not native drawing state from a differently sized popup. // Reuse behavior, not native drawing state from a differently sized popup.
if isViewLoaded { preparePresentation() } else { _ = view } if isViewLoaded { preparePresentation() } else { _ = view }
applicationLabel.stringValue = title applicationLabel.stringValue = title
@@ -248,7 +280,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
entries[0].button.setAccessibilityLabel(openTitle) entries[0].button.setAccessibilityLabel(openTitle)
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil) entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil)
search.setAccessibilityLabel(searchAccessibilityLabel) 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 favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite" let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [ favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
@@ -260,7 +292,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
entries[index].icon.isHidden = currentProviderEntry != index entries[index].icon.isHidden = currentProviderEntry != index
} }
search.placeholderAttributedString = NSAttributedString( 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, attributes: [.foregroundColor: SunsetPalette.secondaryText,
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)]) .font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
search.stringValue = "" search.stringValue = ""
@@ -287,7 +319,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
coveredPill?.setAccessibilityHidden(true) coveredPill?.setAccessibilityHidden(true)
host.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded()
background.needsDisplay = true background.needsDisplay = true
if let selectedEntry { languageTable?.scrollRowToVisible(selectedEntry) } if let selectedEntry { selectionTable?.scrollRowToVisible(selectedEntry) }
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16 context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16
view.animator().alphaValue = 1 view.animator().alphaValue = 1
@@ -345,16 +377,16 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func controlTextDidChange(_ notification: Notification) { func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if let languages, let table = languageTable { if let choices, let table = selectionTable {
matchingLanguages = FuzzySearchText.results(for: query, in: languages, text: \.searchText) { matchingChoices = FuzzySearchText.results(for: query, in: choices, text: \.searchText) {
$0.name.localizedStandardCompare($1.name) == .orderedAscending $0.title.localizedStandardCompare($1.title) == .orderedAscending
} }
let selection = (query.isEmpty ? matchingLanguages.firstIndex(where: { $0.code == currentLanguage }) : nil) let selection = (query.isEmpty ? matchingChoices.firstIndex(where: { $0.id == currentChoice }) : nil)
?? (matchingLanguages.isEmpty ? nil : 0) ?? (matchingChoices.isEmpty ? nil : 0)
for entry in entries { entry.view.isHidden = true } for entry in entries { entry.view.isHidden = true }
languageViewportHeight?.constant = CGFloat(max(min(matchingLanguages.count, 6), 1)) * table.rowHeight selectionViewportHeight?.constant = CGFloat(max(min(matchingChoices.count, 6), 1)) * table.rowHeight
empty.isHidden = !matchingLanguages.isEmpty empty.isHidden = !matchingChoices.isEmpty
languageScroll?.isHidden = matchingLanguages.isEmpty selectionScroll?.isHidden = matchingChoices.isEmpty
view.superview?.layoutSubtreeIfNeeded() view.superview?.layoutSubtreeIfNeeded()
table.reloadData() table.reloadData()
selectedEntry = selection selectedEntry = selection
@@ -380,9 +412,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
} }
func moveSelection(by offset: Int) { func moveSelection(by offset: Int) {
if let table = languageTable { if let table = selectionTable {
guard !matchingLanguages.isEmpty else { return } guard !matchingChoices.isEmpty else { return }
let next = min(max((selectedEntry ?? 0) + offset, 0), matchingLanguages.count - 1) let next = min(max((selectedEntry ?? 0) + offset, 0), matchingChoices.count - 1)
table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false) table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false)
table.scrollRowToVisible(next) table.scrollRowToVisible(next)
return return
@@ -394,10 +426,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
} }
private func updateSelection() { private func updateSelection() {
if let table = languageTable { if let table = selectionTable {
let visible = table.rows(in: table.visibleRect) let visible = table.rows(in: table.visibleRect)
for row in visible.location..<NSMaxRange(visible) { for row in visible.location..<NSMaxRange(visible) {
if let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false) as? LanguageCell { if let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false) as? SelectionCell {
cell.entry.view.isSelected = selectedEntry == row cell.entry.view.isSelected = selectedEntry == row
cell.entry.returnKey.isHidden = selectedEntry != row cell.entry.returnKey.isHidden = selectedEntry != row
} }
@@ -407,7 +439,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
for index in entries.indices { for index in entries.indices {
let selected = selectedEntry == index let selected = selectedEntry == index
entries[index].view.isSelected = selected entries[index].view.isSelected = selected
entries[index].returnKey.isHidden = !selected entries[index].returnKey.isHidden = !selected && !entries[index].hasCommandKey
let current = currentProviderEntry == index ? ", Current provider" : "" let current = currentProviderEntry == index ? ", Current provider" : ""
entries[index].button.setAccessibilityValue((selected ? "Selected" : "Not selected") + current) entries[index].button.setAccessibilityValue((selected ? "Selected" : "Not selected") + current)
} }
@@ -431,32 +463,42 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
return true return true
} }
func numberOfRows(in tableView: NSTableView) -> 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? { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let identifier = NSUserInterfaceItemIdentifier("LanguageCell") let identifier = NSUserInterfaceItemIdentifier("SelectionCell")
let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? LanguageCell ?? LanguageCell() let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? SelectionCell ?? SelectionCell()
cell.identifier = identifier cell.identifier = identifier
let choice = matchingLanguages[row] let choice = matchingChoices[row]
cell.entry.button.title = choice.name cell.entry.button.title = choice.title
cell.entry.button.setAccessibilityLabel(choice.name) cell.entry.button.setAccessibilityLabel(choice.title)
cell.entry.button.setAccessibilityValue(choice.code == currentLanguage ? "Current language" : "") cell.entry.button.setAccessibilityValue(choice.id == currentChoice ? currentChoiceAccessibilityLabel : "")
cell.entry.button.target = self cell.entry.button.target = self
cell.entry.button.action = #selector(chooseButton(_:)) cell.entry.button.action = #selector(chooseButton(_:))
cell.entry.button.tag = row 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.view.isSelected = selectedEntry == row
cell.entry.returnKey.isHidden = selectedEntry != row cell.entry.returnKey.isHidden = selectedEntry != row
return cell return cell
} }
func tableViewSelectionDidChange(_ notification: Notification) { func tableViewSelectionDidChange(_ notification: Notification) {
guard let table = languageTable else { return } guard let table = selectionTable else { return }
selectedEntry = table.selectedRow >= 0 ? table.selectedRow : nil selectedEntry = table.selectedRow >= 0 ? table.selectedRow : nil
updateSelection() updateSelection()
} }
@objc private func chooseLanguageRow() { @objc private func chooseSelectionRow() {
if let selectedEntry { chooseAction(at: selectedEntry) } if let selectedEntry { chooseAction(at: selectedEntry) }
} }
@@ -468,11 +510,14 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private func chooseAction(at index: Int) { private func chooseAction(at index: Int) {
guard isShown else { return } guard isShown else { return }
if languages != nil { if choices != nil {
guard matchingLanguages.indices.contains(index) else { return } guard matchingChoices.indices.contains(index) else { return }
let code = matchingLanguages[index].code let id = matchingChoices[index].id
close() close()
selectLanguage?(code) switch id {
case .language(let code): selectLanguage?(code)
case .note(let id): selectNote?(id)
}
return return
} }
guard visibleEntries.contains(index) else { return } guard visibleEntries.contains(index) else { return }
@@ -490,6 +535,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
case 9: swapLanguages?() case 9: swapLanguages?()
case 10: selectProvider?(false) case 10: selectProvider?(false)
case 11: selectProvider?(true) case 11: selectProvider?(true)
case 12: browseNotes?()
case 13: newNote?()
default: break default: break
} }
} }
@@ -500,9 +547,12 @@ private struct ActionEntry {
let view = LauncherRowView() let view = LauncherRowView()
let button: NSButton let button: NSButton
let icon: IconView 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) let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
icon = IconView(font: font) icon = IconView(font: font)
button = NSButton(title: title, target: nil, action: nil) button = NSButton(title: title, target: nil, action: nil)
@@ -535,7 +585,7 @@ private struct ActionEntry {
} }
@MainActor @MainActor
private final class LanguageCell: NSView { private final class SelectionCell: NSView {
let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text) let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text)
init() { 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 @MainActor
@@ -565,12 +615,16 @@ private final class ActionsPanelView: NSView {
@MainActor @MainActor
private final class ActionsSearchField: NSSearchField { private final class ActionsSearchField: NSSearchField {
var closeActions: (() -> Void)? var closeActions: (() -> Void)?
var commandKey: ((String) -> Bool)?
override func performKeyEquivalent(with event: NSEvent) -> Bool { override func performKeyEquivalent(with event: NSEvent) -> Bool {
if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command, if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
event.charactersIgnoringModifiers?.lowercased() == "k" { let key = event.charactersIgnoringModifiers?.lowercased() {
closeActions?() if key == "k" {
return true closeActions?()
return true
}
if commandKey?(key) == true { return true }
} }
return super.performKeyEquivalent(with: event) return super.performKeyEquivalent(with: event)
} }
+29 -14
View File
@@ -98,8 +98,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
else { self.moveSelection(by: offset) } else { self.moveSelection(by: offset) }
} }
window.commandNavigation = { [weak self] key in 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.usesTextNavigation = { [weak self] in self?.notes != nil }
window.dismiss = { [weak self] in self?.dismiss() } window.dismiss = { [weak self] in self?.dismiss() }
window.escape = { [weak self] in self?.handleEscape() } window.escape = { [weak self] in self?.handleEscape() }
@@ -476,7 +478,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func handleEscape() { private func handleEscape() {
let selector = #selector(NSResponder.cancelOperation(_:)) 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() dismiss()
} }
@@ -485,6 +488,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
handleEscape() handleEscape()
return true return true
} }
if let notes { return notes.handle(commandSelector) }
if let emojiPicker { return emojiPicker.handle(commandSelector) } if let emojiPicker { return emojiPicker.handle(commandSelector) }
if let translation { return translation.handle(commandSelector) } if let translation { return translation.handle(commandSelector) }
switch commandSelector { switch commandSelector {
@@ -807,8 +811,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@discardableResult @discardableResult
private func closeCommand() -> Bool { private func closeCommand() -> Bool {
guard saveNotes() else { return false } guard notes?.close() != false else { return false }
notes?.closed?()
translation?.close() translation?.close()
emojiPicker?.close() emojiPicker?.close()
return true return true
@@ -1200,6 +1203,7 @@ final class LauncherPanel: NSPanel {
var numberHintsChanged: (() -> Void)? var numberHintsChanged: (() -> Void)?
var launchNumber: ((Int) -> Void)? var launchNumber: ((Int) -> Void)?
var commandNavigation: ((String) -> Bool)? var commandNavigation: ((String) -> Bool)?
var commandShortcut: ((String) -> Bool)?
var usesTextNavigation: (() -> Bool)? var usesTextNavigation: (() -> Bool)?
private var numberShortcuts = CommandNumberShortcuts() private var numberShortcuts = CommandNumberShortcuts()
private var commandHoldTask: Task<Void, Never>? private var commandHoldTask: Task<Void, Never>?
@@ -1304,18 +1308,20 @@ final class LauncherPanel: NSPanel {
} }
resetNumberHints(modifierFlags: event.modifierFlags) 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 { event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return } if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return }
switch event.charactersIgnoringModifiers?.lowercased() { if usesTextNavigation?() != true {
case "j": switch event.charactersIgnoringModifiers?.lowercased() {
navigateSelection?(1) case "j":
return navigateSelection?(1)
case "k": return
navigateSelection?(-1) case "k":
return navigateSelection?(-1)
default: return
break default:
break
}
} }
} }
super.sendEvent(event) 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?) { override func cancelOperation(_ sender: Any?) {
if let escape { escape() } else { dismiss?() } if let escape { escape() } else { dismiss?() }
} }
+74 -30
View File
@@ -4,9 +4,9 @@ import Highlighter
@MainActor @MainActor
final class NotesController: NSViewController, NSTextViewDelegate { 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 actionsButton = NSButton(title: "Actions", target: nil, action: nil)
private let noteMenu = NSMenu(title: "Notes") private let actions = ApplicationActionsController()
private let editor = NSTextView() private let editor = NSTextView()
private let status = NSTextField(labelWithString: "") private let status = NSTextField(labelWithString: "")
private let defaults: UserDefaults private let defaults: UserDefaults
@@ -29,16 +29,22 @@ final class NotesController: NSViewController, NSTextViewDelegate {
required init?(coder: NSCoder) { fatalError("Conjure creates Notes programmatically") } required init?(coder: NSCoder) { fatalError("Conjure creates Notes programmatically") }
override func loadView() { override func loadView() {
view = NSView() let content = NotesView()
createButton.target = self content.commandShortcut = { [weak self] in self?.handleCommandKey($0) ?? false }
createButton.action = #selector(newNote) view = content
createButton.toolTip = "Create a note (Command-N)" browseButton.target = self
browseButton.action = #selector(browseNotes)
browseButton.setAccessibilityLabel("Browse notes")
browseButton.toolTip = "Browse notes (Command-P)"
actionsButton.target = self actionsButton.target = self
actionsButton.action = #selector(showActions) actionsButton.action = #selector(showActions)
actionsButton.setAccessibilityLabel("Choose a note") actionsButton.setAccessibilityLabel("Notes actions")
actionsButton.toolTip = "Choose a note (Command-K)" actionsButton.toolTip = "Notes actions (Command-K)"
actionsButton.menu = noteMenu actions.closed = { [weak self] in self?.focusEditor() }
let pill = ActionPill(primaryButton: createButton, actionsButton: actionsButton, primaryCommand: "n") 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]) let footer = NSStackView(views: [status, NSView(), pill])
footer.orientation = .horizontal footer.orientation = .horizontal
footer.alignment = .centerY footer.alignment = .centerY
@@ -91,7 +97,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
]) ])
guard let store else { guard let store else {
editor.isEditable = false editor.isEditable = false
for control in [createButton, actionsButton] { control.isEnabled = false } for control in [browseButton, actionsButton] { control.isEnabled = false }
showError(loadError!) showError(loadError!)
return return
} }
@@ -119,7 +125,6 @@ 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
updateActions()
status.stringValue = "Saved locally · Markdown · Paste or drop images" status.stringValue = "Saved locally · Markdown · Paste or drop images"
status.textColor = SunsetPalette.secondaryText status.textColor = SunsetPalette.secondaryText
return true return true
@@ -129,20 +134,50 @@ final class NotesController: NSViewController, NSTextViewDelegate {
} }
} }
func goBack() { func handle(_ selector: Selector) -> Bool {
guard save() else { return } 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?() closed?()
return true
}
func goBack() {
if actions.isShown { actions.close() }
else { _ = close() }
} }
@objc private func newNote() { @objc private func newNote() {
actions.close(animated: false)
guard save(), let store else { return } guard save(), let store else { return }
do { select(try store.create()); focusEditor() } do { select(try store.create()); focusEditor() }
catch { showError(error) } catch { showError(error) }
} }
@objc private func switchNote(_ sender: NSMenuItem) { private func switchNote(_ id: UUID) {
guard let value = sender.representedObject as? UUID, save() else { return } guard save() else { return }
select(value) select(id)
focusEditor() focusEditor()
} }
@@ -155,20 +190,10 @@ 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)
updateActions()
status.stringValue = "Saved locally · Markdown · Paste or drop images" status.stringValue = "Saved locally · Markdown · Paste or drop images"
status.textColor = SunsetPalette.secondaryText 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) { private func showError(_ error: Error) {
status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)" status.stringValue = "Couldn’t save or load notes: \(error.localizedDescription)"
@@ -176,9 +201,28 @@ final class NotesController: NSViewController, NSTextViewDelegate {
status.textColor = .systemRed status.textColor = .systemRed
} }
@objc private func showActions() { @objc func browseNotes() {
noteMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: actionsButton.bounds.maxY + 4), in: actionsButton) guard let store else { return }
focusEditor() 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)
} }
} }
+2
View File
@@ -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.
@@ -3,7 +3,7 @@ import ConjureKit
import Testing import Testing
@testable import Conjure @testable import Conjure
@Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "translate-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 { @MainActor func escapeClosesPickerBeforeDismissingLauncher(_ destination: String) throws {
_ = NSApplication.shared _ = NSApplication.shared
let suite = "ConjureEscape.\(UUID().uuidString)" let suite = "ConjureEscape.\(UUID().uuidString)"
@@ -58,6 +58,14 @@ import Testing
$0.accessibilityLabel() == "Japanese translation" $0.accessibilityLabel() == "Japanese translation"
}) })
window.makeFirstResponder(output) 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" { } else if destination == "notes-editor" {
let editor = try #require(descendants(content).compactMap { $0 as? NSTextView }.first { let editor = try #require(descendants(content).compactMap { $0 as? NSTextView }.first {
$0.accessibilityLabel() == "Note editor" $0.accessibilityLabel() == "Note editor"
@@ -70,11 +78,21 @@ import Testing
windowNumber: window.windowNumber, context: nil, characters: "\u{1b}", windowNumber: window.windowNumber, context: nil, characters: "\u{1b}",
charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)) charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53))
window.sendEvent(escape) 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.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) window.sendEvent(escape)
} }
#expect(!window.isVisible) #expect(!window.isVisible)
#expect(!descendants(content).contains { $0 is NSCollectionView }) #expect(!descendants(content).contains { $0 is NSCollectionView })
#expect(!descendants(content).compactMap { $0 as? NSSearchField }.contains {
$0.accessibilityLabel() == "Search notes" || $0.accessibilityLabel() == "Search notes actions"
})
} }
@@ -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)
}
} }
+149 -13
View File
@@ -16,27 +16,52 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
let controller = NotesController(defaults: defaults, directory: directory) 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) let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 390), styleMask: [.borderless], backing: .buffered, defer: false)
window.contentViewController = controller window.contentViewController = controller
defer { window.orderOut(nil) } defer { _ = controller.close(); 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? NSTextView }.first) 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 browse = try #require(views.compactMap { $0 as? NSButton }.first { $0.title == "Browse Notes" })
let menu = try #require(actions.menu) #expect(browse.keyEquivalent == "p")
let firstID = try #require(menu.items.first { $0.state == .on }?.representedObject as? UUID) #expect(!views.compactMap { $0 as? NSButton }.contains { $0.title == "New Note" })
// Empty notes have duplicate titles; both must remain individually selectable. 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) window.makeFirstResponder(editor)
let newNoteKey = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, let newNoteKey = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero,
modifierFlags: .command, timestamp: 1, windowNumber: window.windowNumber, context: nil, modifierFlags: .command, timestamp: 1, windowNumber: window.windowNumber, context: nil,
characters: "n", charactersIgnoringModifiers: "n", isARepeat: false, keyCode: 45)) characters: "n", charactersIgnoringModifiers: "n", isARepeat: false, keyCode: 45))
#expect(window.performKeyEquivalent(with: newNoteKey)) #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) #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()) 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)
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.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())
@@ -58,7 +83,7 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)) characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36))
editor.keyDown(with: returnKey) editor.keyDown(with: returnKey)
#expect(editor.string == "First note\u{fffc}\u{fffc}\n") #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 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}\u{fffc}\n") #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(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 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 reopenedViews = descendants(reopened.view)
let reopenedEditor = try #require(reopenedViews.compactMap { $0 as? NSTextView }.first) let reopenedEditor = try #require(reopenedViews.compactMap { $0 as? NSTextView }.first)
#expect(reopenedEditor.string == first.contents.string) #expect(reopenedEditor.string == first.contents.string)
@@ -83,14 +111,122 @@ func notesSwitchWithoutLeakingEditsUndoOrAttachmentsAndRestoreSelection() throws
reopened.goBack() reopened.goBack()
#expect(!closed) #expect(!closed)
#expect(!reopened.save()) #expect(!reopened.save())
let reopenedActions = try #require(reopenedViews.compactMap { $0 as? NSButton }.first { $0.accessibilityLabel() == "Choose a note" }) reopened.browseNotes()
let reopenedMenu = try #require(reopenedActions.menu) filter(try picker(reopened), "scnd")
reopenedMenu.performActionForItem(at: try #require(reopenedMenu.items.firstIndex { $0.representedObject as? UUID == secondID })) #expect(reopened.handle(#selector(NSResponder.insertNewline(_:))))
#expect(reopenedEditor.string == "Unsaved") #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.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())
reopened.goBack() #expect(reopened.close())
#expect(closed) #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)
} }