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
@@ -33,17 +33,26 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
var selectProvider: ((Bool) -> Void)?
private var currentProviderEntry: Int?
var selectLanguage: ((String) -> Void)?
private struct LanguageChoice {
let code: String
let name: String
var selectNote: ((UUID) -> Void)?
var browseNotes: (() -> Void)?
var newNote: (() -> Void)?
private enum ChoiceID: Equatable {
case language(String)
case note(UUID)
}
private struct SelectionChoice {
let id: ChoiceID
let title: String
let searchText: FuzzySearchText
}
private var languages: [LanguageChoice]?
private var matchingLanguages: [LanguageChoice] = []
private var currentLanguage: String?
private var languageTable: NSTableView?
private var languageScroll: NSScrollView?
private var languageViewportHeight: NSLayoutConstraint?
private var choices: [SelectionChoice]?
private var matchingChoices: [SelectionChoice] = []
private var currentChoice: ChoiceID?
private var selectionTable: NSTableView?
private var selectionScroll: NSScrollView?
private var selectionViewportHeight: NSLayoutConstraint?
private var selectionAccessibilityLabel = ""
private var currentChoiceAccessibilityLabel = ""
var closed: (() -> Void)?
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
@@ -58,9 +67,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
empty = NSTextField(labelWithString: "No matching actions")
applicationLabel = NSTextField(labelWithString: "")
rowTopConstraints = []
languageTable = nil
languageScroll = nil
languageViewportHeight = nil
selectionTable = nil
selectionScroll = nil
selectionViewportHeight = nil
entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text),
@@ -73,7 +82,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
ActionEntry(title: "Change target language…", symbol: "character.bubble", color: SunsetPalette.text),
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: SunsetPalette.text),
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: SunsetPalette.text),
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text)
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text),
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"),
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N")
]
view = ActionsPanelView()
view.wantsLayer = true
@@ -108,6 +119,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
search.sendsSearchStringImmediately = true
search.setAccessibilityLabel("Search application actions")
search.closeActions = { [weak self] in self?.close() }
search.commandKey = { [weak self] key in self?.handleCommandKey(key) ?? false }
empty.textColor = SunsetPalette.secondaryText
empty.font = .systemFont(ofSize: 14)
empty.isHidden = true
@@ -156,11 +168,11 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
search.heightAnchor.constraint(equalToConstant: 24)
])
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
if languages != nil {
if choices != nil {
height.isActive = false
let table = NSTableView()
table.style = .plain
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Language")))
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Selection")))
table.headerView = nil
table.backgroundColor = .clear
table.selectionHighlightStyle = .none
@@ -172,8 +184,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
table.dataSource = self
table.delegate = self
table.target = self
table.action = #selector(chooseLanguageRow)
table.setAccessibilityLabel("Translation languages")
table.action = #selector(chooseSelectionRow)
table.setAccessibilityLabel(selectionAccessibilityLabel)
let scroll = NSScrollView()
scroll.documentView = table
scroll.hasVerticalScroller = true
@@ -189,9 +201,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
scroll.bottomAnchor.constraint(equalTo: search.topAnchor, constant: -8),
viewportHeight,
])
languageTable = table
languageScroll = scroll
languageViewportHeight = viewportHeight
selectionTable = table
selectionScroll = scroll
selectionViewportHeight = viewportHeight
}
updateSelection()
}
@@ -223,22 +235,42 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func showLanguages(_ choices: [(code: String, name: String)], selected: String,
source: Bool, relativeTo anchor: NSView) {
currentLanguage = selected
currentChoice = .language(selected)
selectionAccessibilityLabel = "Translation languages"
currentChoiceAccessibilityLabel = "Current language"
show(title: source ? "Source language" : "Target language", relativeTo: anchor, available: [],
searchAccessibilityLabel: source ? "Search source languages" : "Search target languages",
languageChoices: choices.map { LanguageChoice(code: $0.code, name: $0.name, searchText: FuzzySearchText($0.name)) })
selectionChoices: choices.map {
SelectionChoice(id: .language($0.code), title: $0.name, searchText: FuzzySearchText($0.name))
}, selectionName: "languages")
}
func showNotes(_ notes: [(id: UUID, title: String)], selected: UUID?, relativeTo anchor: NSView) {
currentChoice = selected.map { .note($0) }
selectionAccessibilityLabel = "Notes"
currentChoiceAccessibilityLabel = "Current note"
show(title: "Browse Notes", relativeTo: anchor, available: [],
searchAccessibilityLabel: "Search notes",
selectionChoices: notes.map {
SelectionChoice(id: .note($0.id), title: $0.title, searchText: FuzzySearchText($0.title))
}, selectionName: "notes")
}
func showNotesActions(relativeTo anchor: NSView) {
show(title: "Notes", relativeTo: anchor, available: [12, 13],
searchAccessibilityLabel: "Search notes actions")
}
private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false,
searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false,
languageChoices: [LanguageChoice]? = nil) {
selectionChoices: [SelectionChoice]? = nil, selectionName: String = "") {
if viewIfLoaded?.superview != nil {
if isShown, availableEntries == available, applicationLabel.stringValue == title { close(); return }
close(animated: false)
}
guard let window = anchor.window, let host = window.contentView else { return }
let pill = anchor.superview ?? anchor
languages = languageChoices
choices = selectionChoices
// Reuse behavior, not native drawing state from a differently sized popup.
if isViewLoaded { preparePresentation() } else { _ = view }
applicationLabel.stringValue = title
@@ -248,7 +280,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
entries[0].button.setAccessibilityLabel(openTitle)
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil)
search.setAccessibilityLabel(searchAccessibilityLabel)
empty.stringValue = languages == nil ? "No matching actions" : "No matching languages"
empty.stringValue = choices == nil ? "No matching actions" : "No matching \(selectionName)"
let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
@@ -260,7 +292,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
entries[index].icon.isHidden = currentProviderEntry != index
}
search.placeholderAttributedString = NSAttributedString(
string: languages != nil ? "Search for languages…" : belowAnchor ? "Search for providers…" : "Search for actions…",
string: choices != nil ? "Search for \(selectionName)…" : belowAnchor ? "Search for providers…" : "Search for actions…",
attributes: [.foregroundColor: SunsetPalette.secondaryText,
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
search.stringValue = ""
@@ -287,7 +319,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
coveredPill?.setAccessibilityHidden(true)
host.layoutSubtreeIfNeeded()
background.needsDisplay = true
if let selectedEntry { languageTable?.scrollRowToVisible(selectedEntry) }
if let selectedEntry { selectionTable?.scrollRowToVisible(selectedEntry) }
NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16
view.animator().alphaValue = 1
@@ -345,16 +377,16 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
if let languages, let table = languageTable {
matchingLanguages = FuzzySearchText.results(for: query, in: languages, text: \.searchText) {
$0.name.localizedStandardCompare($1.name) == .orderedAscending
if let choices, let table = selectionTable {
matchingChoices = FuzzySearchText.results(for: query, in: choices, text: \.searchText) {
$0.title.localizedStandardCompare($1.title) == .orderedAscending
}
let selection = (query.isEmpty ? matchingLanguages.firstIndex(where: { $0.code == currentLanguage }) : nil)
?? (matchingLanguages.isEmpty ? nil : 0)
let selection = (query.isEmpty ? matchingChoices.firstIndex(where: { $0.id == currentChoice }) : nil)
?? (matchingChoices.isEmpty ? nil : 0)
for entry in entries { entry.view.isHidden = true }
languageViewportHeight?.constant = CGFloat(max(min(matchingLanguages.count, 6), 1)) * table.rowHeight
empty.isHidden = !matchingLanguages.isEmpty
languageScroll?.isHidden = matchingLanguages.isEmpty
selectionViewportHeight?.constant = CGFloat(max(min(matchingChoices.count, 6), 1)) * table.rowHeight
empty.isHidden = !matchingChoices.isEmpty
selectionScroll?.isHidden = matchingChoices.isEmpty
view.superview?.layoutSubtreeIfNeeded()
table.reloadData()
selectedEntry = selection
@@ -380,9 +412,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
}
func moveSelection(by offset: Int) {
if let table = languageTable {
guard !matchingLanguages.isEmpty else { return }
let next = min(max((selectedEntry ?? 0) + offset, 0), matchingLanguages.count - 1)
if let table = selectionTable {
guard !matchingChoices.isEmpty else { return }
let next = min(max((selectedEntry ?? 0) + offset, 0), matchingChoices.count - 1)
table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false)
table.scrollRowToVisible(next)
return
@@ -394,10 +426,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
}
private func updateSelection() {
if let table = languageTable {
if let table = selectionTable {
let visible = table.rows(in: table.visibleRect)
for row in visible.location..<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.returnKey.isHidden = selectedEntry != row
}
@@ -407,7 +439,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
for index in entries.indices {
let selected = selectedEntry == index
entries[index].view.isSelected = selected
entries[index].returnKey.isHidden = !selected
entries[index].returnKey.isHidden = !selected && !entries[index].hasCommandKey
let current = currentProviderEntry == index ? ", Current provider" : ""
entries[index].button.setAccessibilityValue((selected ? "Selected" : "Not selected") + current)
}
@@ -431,32 +463,42 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
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? {
let identifier = NSUserInterfaceItemIdentifier("LanguageCell")
let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? LanguageCell ?? LanguageCell()
let identifier = NSUserInterfaceItemIdentifier("SelectionCell")
let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? SelectionCell ?? SelectionCell()
cell.identifier = identifier
let choice = matchingLanguages[row]
cell.entry.button.title = choice.name
cell.entry.button.setAccessibilityLabel(choice.name)
cell.entry.button.setAccessibilityValue(choice.code == currentLanguage ? "Current language" : "")
let choice = matchingChoices[row]
cell.entry.button.title = choice.title
cell.entry.button.setAccessibilityLabel(choice.title)
cell.entry.button.setAccessibilityValue(choice.id == currentChoice ? currentChoiceAccessibilityLabel : "")
cell.entry.button.target = self
cell.entry.button.action = #selector(chooseButton(_:))
cell.entry.button.tag = row
cell.entry.icon.isHidden = choice.code != currentLanguage
cell.entry.icon.isHidden = choice.id != currentChoice
cell.entry.view.isSelected = selectedEntry == row
cell.entry.returnKey.isHidden = selectedEntry != row
return cell
}
func tableViewSelectionDidChange(_ notification: Notification) {
guard let table = languageTable else { return }
guard let table = selectionTable else { return }
selectedEntry = table.selectedRow >= 0 ? table.selectedRow : nil
updateSelection()
}
@objc private func chooseLanguageRow() {
@objc private func chooseSelectionRow() {
if let selectedEntry { chooseAction(at: selectedEntry) }
}
@@ -468,11 +510,14 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private func chooseAction(at index: Int) {
guard isShown else { return }
if languages != nil {
guard matchingLanguages.indices.contains(index) else { return }
let code = matchingLanguages[index].code
if choices != nil {
guard matchingChoices.indices.contains(index) else { return }
let id = matchingChoices[index].id
close()
selectLanguage?(code)
switch id {
case .language(let code): selectLanguage?(code)
case .note(let id): selectNote?(id)
}
return
}
guard visibleEntries.contains(index) else { return }
@@ -490,6 +535,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
case 9: swapLanguages?()
case 10: selectProvider?(false)
case 11: selectProvider?(true)
case 12: browseNotes?()
case 13: newNote?()
default: break
}
}
@@ -500,9 +547,12 @@ private struct ActionEntry {
let view = LauncherRowView()
let button: NSButton
let icon: IconView
let returnKey = ShortcutIndicator("↵")
let returnKey: ShortcutIndicator
let hasCommandKey: Bool
init(title: String, symbol: String, color: NSColor) {
init(title: String, symbol: String, color: NSColor, commandKey: String? = nil) {
hasCommandKey = commandKey != nil
returnKey = commandKey.map { ShortcutIndicator("⌘", $0) } ?? ShortcutIndicator("↵")
let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
icon = IconView(font: font)
button = NSButton(title: title, target: nil, action: nil)
@@ -535,7 +585,7 @@ private struct ActionEntry {
}
@MainActor
private final class LanguageCell: NSView {
private final class SelectionCell: NSView {
let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text)
init() {
@@ -550,7 +600,7 @@ private final class LanguageCell: NSView {
])
}
required init?(coder: NSCoder) { fatalError("Conjure creates language rows programmatically") }
required init?(coder: NSCoder) { fatalError("Conjure creates selection rows programmatically") }
}
@MainActor
@@ -565,12 +615,16 @@ private final class ActionsPanelView: NSView {
@MainActor
private final class ActionsSearchField: NSSearchField {
var closeActions: (() -> Void)?
var commandKey: ((String) -> Bool)?
override func performKeyEquivalent(with event: NSEvent) -> Bool {
if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
event.charactersIgnoringModifiers?.lowercased() == "k" {
closeActions?()
return true
let key = event.charactersIgnoringModifiers?.lowercased() {
if key == "k" {
closeActions?()
return true
}
if commandKey?(key) == true { return true }
}
return super.performKeyEquivalent(with: event)
}