style: use shared notes browsing and actions panels
This commit is contained in:
@@ -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,13 +615,17 @@ 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() {
|
||||||
|
if key == "k" {
|
||||||
closeActions?()
|
closeActions?()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if commandKey?(key) == true { return true }
|
||||||
|
}
|
||||||
return super.performKeyEquivalent(with: event)
|
return super.performKeyEquivalent(with: event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +1308,10 @@ 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 }
|
||||||
|
if usesTextNavigation?() != true {
|
||||||
switch event.charactersIgnoringModifiers?.lowercased() {
|
switch event.charactersIgnoringModifiers?.lowercased() {
|
||||||
case "j":
|
case "j":
|
||||||
navigateSelection?(1)
|
navigateSelection?(1)
|
||||||
@@ -1318,12 +1323,22 @@ final class LauncherPanel: NSPanel {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
super.sendEvent(event)
|
super.sendEvent(event)
|
||||||
if event.type == .leftMouseUp || (event.type == .leftMouseDown && NSEvent.pressedMouseButtons & 1 == 0) {
|
if event.type == .leftMouseUp || (event.type == .leftMouseDown && NSEvent.pressedMouseButtons & 1 == 0) {
|
||||||
dragEnded?()
|
dragEnded?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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?() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
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())
|
#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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user