Compare commits

..

5 Commits

13 changed files with 619 additions and 118 deletions
+14 -4
View File
@@ -1,6 +1,6 @@
# Conjure
A native macOS launcher for applications, calculations, unit and currency conversions, emoji, notes, and English → Japanese translation.
A native macOS launcher for applications, calculations, unit and currency conversions, emoji, notes, English → Japanese translation, and light/dark system theme switching.
## Build and install
@@ -71,9 +71,9 @@ The header’s trailing edge is constrained to the footer’s trailing edge. The
### Global shortcuts and launch at login
In Settings, choose a **Global shortcut destination**: **Applications**, **Conjure Emoji**, **Conjure Notes**, or **Conjure Translate**. Choose **Record Shortcut…** and press a combination containing Command, Option, or Control. For example, select **Conjure Emoji** and record Command-Shift-Space to open the emoji picker directly. **Clear** disables only the selected destination's shortcut. No shortcuts are assigned by default; existing launcher bindings are preserved.
In Settings, choose a **Global shortcut destination**: **Applications**, **Conjure Emoji**, **Conjure Notes**, **Conjure Translate**, or **Toggle System Theme**. Choose **Record Shortcut…** and press a combination containing Command, Option, or Control. For example, select **Conjure Emoji** and record Command-Shift-Space to open the emoji picker directly. **Clear** disables only the selected destination's shortcut. No shortcuts are assigned by default; existing launcher bindings are preserved.
The Applications shortcut always targets the application launcher, not the last-used tool. Command shortcuts open their selected tool directly. Pressing a shortcut while its destination is already frontmost dismisses Conjure; switching destinations opens a fresh view. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation.
The Applications shortcut always targets the application launcher, not the last-used tool. View commands open their selected tool directly. Action-only shortcuts, such as Toggle System Theme, execute in the background without opening the launcher, taking focus, clearing a search, or closing an existing tool. Pressing a tool shortcut while its destination is already frontmost dismisses Conjure; switching view destinations opens a fresh view. Active confirmations and uninstall operations are not interrupted. All global shortcuts pause while recording and resume afterward. Conjure rejects shortcuts already assigned to another destination or used by its own menus and navigation.
Cancelling recording, changing destination, or leaving the recorder restores saved shortcuts. If a replacement cannot be registered, the previous saved binding is retained; any failure to restore a shortcut is reported in Settings.
@@ -124,6 +124,14 @@ With Actions open, Escape or the Back chevron closes Actions first. Another Esca
The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application.
## System theme
Search for **Toggle System Theme**, identified by its centered 🌗 icon (try `theme` or `system`), and press Return to switch macOS from light to dark appearance or back. Selecting it in the launcher dismisses Conjure on success. Its global shortcut runs in the background without presenting a window or changing the current launcher view. You can favorite it or assign a shortcut in Settings; existing theme keybindings and favorites survive the rename.
The first use may ask permission to control **System Events**. Allow it under **System Settings → Privacy & Security → Automation → Conjure**. If permission is denied or macOS reports an error, Conjure shows the failure in the visible launcher or a standalone alert when the launcher is hidden. Conjure's own launcher keeps its existing dark styling.
Commands explicitly declare `Application.Command.presentsView`. The shared dispatcher routes action-only commands before any launcher presentation; new commands must choose their behavior in the exhaustive metadata switch. Command IDs remain independent of display names so renaming does not reset saved shortcuts, favorites, or usage.
## Notes
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. **New Note** (Command-N) creates a note; the menu switches between notes, and reopening Notes restores the last selection. Each note’s first nonempty line becomes its menu title.
@@ -148,7 +156,9 @@ The footer places the current primary action and its Return keycap before **Acti
Press Command-P or click the provider selector to expand a searchable action-style popup from its top edge downwards, hiding the indicator until the popup closes. A checkmark marks the current provider. Use Up/Down and Return or click a provider to select it; Escape, Back, or clicking outside closes the popup. Changing providers clears the previous result without submitting a request. Switching away from OpenAI during the context step restores the original source text; context is not sent to MyMemory.
The footer shows the current language pair (initially **English → Japanese**). Click it or open **Actions** with Command-K to change the source language, change the target language, or swap them. Language choices are saved locally and apply to both providers. Changing languages clears the previous result without submitting another request. The input hint and output accessibility label follow the selected pair. Escape closes an open action/provider picker first and dismisses the window only when no picker is open. Back closes the picker first, then returns from the context step to editing the original text (discarding context), then returns to the launcher and restores your search. Command-W always dismisses the entire window.
The footer shows the current language pair (initially **English → Japanese**). Click it or open **Actions** with Command-K to change the source language, change the target language, or swap them. Source and target selection use Conjure's themed popup, not a native menu. Fuzzy-search language names (for example, `jpns` matches **Japanese**), scroll the full list, and select with Up/Down, Control-J/K, and Return or by clicking a language. A checkmark marks the current language; it is selected and scrolled into view when the picker opens. Escape, Back, Command-K, or clicking outside cancels the picker without changing languages or leaving Translate. Language choices are saved locally and apply to both providers. Changing languages clears the previous result without submitting another request. The input hint and output accessibility label follow the selected pair. Escape closes an open picker first and dismisses the window only when no picker is open. Back closes the picker first, then returns from the context step to editing the original text (discarding context), then returns to the launcher and restores your search. Command-W always dismisses the entire window.
The language picker resizes to complete rows as results are filtered, showing up to six rows before scrolling.
Editing the input, changing languages or providers, saving or forgetting the key in Settings, or leaving Translate cancels the active request; cancelled results are not shown. After a request fails, submit again to retry.
+2
View File
@@ -28,6 +28,8 @@
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>Conjure uses System Events to switch macOS between light and dark appearance.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
@@ -2,7 +2,7 @@ import AppKit
import ConjureKit
@MainActor
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate {
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private var search: ActionsSearchField!
private var background: LauncherBackground!
private var entries: [ActionEntry] = []
@@ -32,6 +32,18 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
var swapLanguages: (() -> Void)?
var selectProvider: ((Bool) -> Void)?
private var currentProviderEntry: Int?
var selectLanguage: ((String) -> Void)?
private struct LanguageChoice {
let code: String
let name: 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?
var closed: (() -> Void)?
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
@@ -46,6 +58,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
empty = NSTextField(labelWithString: "No matching actions")
applicationLabel = NSTextField(labelWithString: "")
rowTopConstraints = []
languageTable = nil
languageScroll = nil
languageViewportHeight = nil
entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text),
@@ -141,6 +156,43 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
search.heightAnchor.constraint(equalToConstant: 24)
])
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
if languages != nil {
height.isActive = false
let table = NSTableView()
table.style = .plain
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Language")))
table.headerView = nil
table.backgroundColor = .clear
table.selectionHighlightStyle = .none
table.rowHeight = LauncherRowView.rowHeight
table.intercellSpacing = .zero
table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle
table.autoresizingMask = [.width]
table.focusRingType = .none
table.dataSource = self
table.delegate = self
table.target = self
table.action = #selector(chooseLanguageRow)
table.setAccessibilityLabel("Translation languages")
let scroll = NSScrollView()
scroll.documentView = table
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.drawsBackground = false
scroll.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(scroll)
let viewportHeight = scroll.heightAnchor.constraint(equalToConstant: LauncherRowView.rowHeight)
NSLayoutConstraint.activate([
scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
scroll.topAnchor.constraint(equalTo: applicationLabel.bottomAnchor, constant: 10),
scroll.bottomAnchor.constraint(equalTo: search.topAnchor, constant: -8),
viewportHeight,
])
languageTable = table
languageScroll = scroll
languageViewportHeight = viewportHeight
}
updateSelection()
}
@@ -169,14 +221,24 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
}
}
func showLanguages(_ choices: [(code: String, name: String)], selected: String,
source: Bool, relativeTo anchor: NSView) {
currentLanguage = selected
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)) })
}
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) {
if viewIfLoaded?.superview != nil {
if isShown, availableEntries == available { close(); return }
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
// Reuse behavior, not native drawing state from a differently sized popup.
if isViewLoaded { preparePresentation() } else { _ = view }
applicationLabel.stringValue = title
@@ -186,6 +248,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"
let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
@@ -197,7 +260,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
entries[index].icon.isHidden = currentProviderEntry != index
}
search.placeholderAttributedString = NSAttributedString(
string: belowAnchor ? "Search for providers…" : "Search for actions…",
string: languages != nil ? "Search for languages…" : belowAnchor ? "Search for providers…" : "Search for actions…",
attributes: [.foregroundColor: SunsetPalette.secondaryText,
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
search.stringValue = ""
@@ -224,6 +287,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
coveredPill?.setAccessibilityHidden(true)
host.layoutSubtreeIfNeeded()
background.needsDisplay = true
if let selectedEntry { languageTable?.scrollRowToVisible(selectedEntry) }
NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16
view.animator().alphaValue = 1
@@ -281,6 +345,26 @@ 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
}
let selection = (query.isEmpty ? matchingLanguages.firstIndex(where: { $0.code == currentLanguage }) : nil)
?? (matchingLanguages.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
view.superview?.layoutSubtreeIfNeeded()
table.reloadData()
selectedEntry = selection
if let selectedEntry {
table.selectRowIndexes(IndexSet(integer: selectedEntry), byExtendingSelection: false)
table.scrollRowToVisible(selectedEntry)
}
updateSelection()
return
}
visibleEntries = availableEntries.filter {
query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
}
@@ -296,6 +380,13 @@ 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)
table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false)
table.scrollRowToVisible(next)
return
}
guard !visibleEntries.isEmpty else { return }
let current = selectedEntry.flatMap { visibleEntries.firstIndex(of: $0) } ?? 0
selectedEntry = visibleEntries[min(max(current + offset, 0), visibleEntries.count - 1)]
@@ -303,6 +394,16 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
}
private func updateSelection() {
if let table = languageTable {
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 {
cell.entry.view.isSelected = selectedEntry == row
cell.entry.returnKey.isHidden = selectedEntry != row
}
}
return
}
for index in entries.indices {
let selected = selectedEntry == index
entries[index].view.isSelected = selected
@@ -330,6 +431,35 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
return true
}
func numberOfRows(in tableView: NSTableView) -> Int { matchingLanguages.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()
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" : "")
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.view.isSelected = selectedEntry == row
cell.entry.returnKey.isHidden = selectedEntry != row
return cell
}
func tableViewSelectionDidChange(_ notification: Notification) {
guard let table = languageTable else { return }
selectedEntry = table.selectedRow >= 0 ? table.selectedRow : nil
updateSelection()
}
@objc private func chooseLanguageRow() {
if let selectedEntry { chooseAction(at: selectedEntry) }
}
@objc private func chooseButton(_ sender: NSButton) { chooseAction(at: sender.tag) }
@objc private func chooseRow(_ sender: NSGestureRecognizer) {
@@ -337,7 +467,15 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
}
private func chooseAction(at index: Int) {
guard isShown, visibleEntries.contains(index) else { return }
guard isShown else { return }
if languages != nil {
guard matchingLanguages.indices.contains(index) else { return }
let code = matchingLanguages[index].code
close()
selectLanguage?(code)
return
}
guard visibleEntries.contains(index) else { return }
close()
switch index {
case 0: open?()
@@ -396,6 +534,25 @@ private struct ActionEntry {
}
}
@MainActor
private final class LanguageCell: NSView {
let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text)
init() {
super.init(frame: .zero)
entry.view.translatesAutoresizingMaskIntoConstraints = false
addSubview(entry.view)
NSLayoutConstraint.activate([
entry.view.leadingAnchor.constraint(equalTo: leadingAnchor),
entry.view.trailingAnchor.constraint(equalTo: trailingAnchor),
entry.view.topAnchor.constraint(equalTo: topAnchor),
entry.view.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
required init?(coder: NSCoder) { fatalError("Conjure creates language rows programmatically") }
}
@MainActor
private final class ActionsPanelView: NSView {
var acceptsInteraction = true
+54 -7
View File
@@ -38,12 +38,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var emojiPicker: EmojiPickerController?
private var notes: NotesController?
private let notesDirectory: URL?
private let toggleSystemTheme: @MainActor () async throws -> Void
private var isCommandOpen: Bool { activeCommand != nil }
private var activeCommand: Application.Command?
private var pendingPresentation: (() -> Void)?
private static let emojiIcon = makeEmojiIcon("🤔")
private static let notesIcon = makeEmojiIcon("📔")
private static let translateIcon = makeEmojiIcon("🌏")
private static let themeIcon = makeEmojiIcon("🌗")
private static func makeEmojiIcon(_ emoji: String) -> NSImage {
let text = NSAttributedString(string: emoji, attributes: [.font: NSFont.systemFont(ofSize: 28)])
@@ -59,12 +61,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller(),
defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general, notesDirectory: URL? = nil) {
defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general, notesDirectory: URL? = nil,
toggleSystemTheme: @escaping @MainActor () async throws -> Void = { try await SystemTheme.toggle() }) {
self.history = history
self.uninstaller = uninstaller
self.defaults = defaults
self.pasteboard = pasteboard
self.notesDirectory = notesDirectory
self.toggleSystemTheme = toggleSystemTheme
let window = LauncherPanel(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: [.borderless],
@@ -128,6 +132,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func present(command: Application.Command? = nil) {
guard !isLaunching, window?.attachedSheet == nil else { return }
if let command, !command.presentsView {
guard launcherFrameBeforeReview == nil else { return }
executeCommand(command)
return
}
if launcherFrameBeforeReview != nil {
pendingPresentation = { [weak self] in self?.present(command: command) }
uninstallReview?.goBack()
@@ -144,8 +153,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
restorePosition()
updateResults()
if let command {
showCommand(command)
history.recordLaunch(of: Application(command: command))
executeCommand(command)
}
window?.deminiaturize(nil)
showWindow(nil)
@@ -558,6 +566,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
case .notes:
cell.imageView?.image = Self.notesIcon
cell.toolTip = "Write notes with Markdown and images"
case .toggleTheme:
cell.imageView?.image = Self.themeIcon
cell.toolTip = "Switch macOS between light and dark appearance"
default:
cell.imageView?.image = Self.translateIcon
cell.toolTip = "Translate English to Japanese"
@@ -733,8 +744,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func openApplication(_ application: Application) {
guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if let command = application.command {
showCommand(command)
history.recordLaunch(of: application)
executeCommand(command, dismissOnSuccess: true)
return
}
(window as? LauncherPanel)?.resetNumberHints()
@@ -800,9 +810,42 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return true
}
private func showCommand(_ command: Application.Command) {
private func runAction(_ command: Application.Command, dismissOnSuccess: Bool) {
let action: @MainActor () async throws -> Void
switch command {
case .toggleTheme: action = toggleSystemTheme
case .translate, .emoji, .notes:
preconditionFailure("View commands must be presented, not run as actions")
}
if dismissOnSuccess { actions.close(animated: false) }
isLaunching = true
updateOpenButton()
Task {
do {
try await action()
history.recordLaunch(of: Application(command: command))
isLaunching = false
if dismissOnSuccess { dismiss() }
} catch {
let alert = NSAlert(error: error)
alert.messageText = "Couldn’t run \(Application(command: command).name)"
if let window, window.isVisible {
await alert.beginSheetModal(for: window)
} else {
alert.runModal()
}
}
isLaunching = false
updateOpenButton()
}
}
private func executeCommand(_ command: Application.Command, dismissOnSuccess: Bool = false) {
guard command.presentsView else {
runAction(command, dismissOnSuccess: dismissOnSuccess)
return
}
guard !isCommandOpen, let content = window?.contentView else { return }
activeCommand = command
let controller: NSViewController
let placeholder: String
let accessibilityLabel: String
@@ -841,7 +884,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
controller = tool
placeholder = "Conjure Notes"
accessibilityLabel = "Conjure Notes"
case .toggleTheme:
preconditionFailure("Action commands must run without presenting a view")
}
activeCommand = command
history.recordLaunch(of: Application(command: command))
let previousQuery = searchField.stringValue
let previousPlaceholder = searchField.placeholderAttributedString
let close: () -> Void = { [weak self, weak controller] in
+28
View File
@@ -0,0 +1,28 @@
import Foundation
enum SystemTheme {
static func toggle() async throws {
try await Task.detached {
guard let script = NSAppleScript(source: """
tell application "System Events"
tell appearance preferences
set dark mode to not dark mode
end tell
end tell
""") else {
throw NSError(domain: "Conjure.SystemTheme", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Couldn’t create the system appearance script.",
])
}
var error: NSDictionary?
script.executeAndReturnError(&error)
if let error {
let code = error[NSAppleScript.errorNumber] as? Int ?? 1
let message = code == -1743
? "Allow Conjure to control System Events in System Settings → Privacy & Security → Automation, then try again."
: error[NSAppleScript.errorMessage] as? String ?? "macOS couldn’t change the system appearance."
throw NSError(domain: "Conjure.SystemTheme", code: code, userInfo: [NSLocalizedDescriptionKey: message])
}
}.value
}
}
+8 -16
View File
@@ -220,23 +220,15 @@ final class TranslationController: NSViewController {
}
private func chooseLanguage(source: Bool) {
let menu = NSMenu(title: source ? "Source language" : "Target language")
for code in Self.languages {
let item = NSMenuItem(title: languageName(code), action: #selector(languageSelected(_:)), keyEquivalent: "")
item.target = self
item.representedObject = code
item.tag = source ? 0 : 1
item.state = code == (source ? sourceLanguage : targetLanguage) ? .on : .off
menu.addItem(item)
actions.selectLanguage = { [weak self] code in
guard let self else { return }
self.setLanguages(source: source ? code : self.sourceLanguage,
target: source ? self.targetLanguage : code)
}
menu.popUp(positioning: menu.items.first { $0.state == .on },
at: NSPoint(x: 0, y: languageButton.bounds.maxY), in: languageButton)
}
@objc private func languageSelected(_ item: NSMenuItem) {
guard let code = item.representedObject as? String else { return }
setLanguages(source: item.tag == 0 ? code : sourceLanguage,
target: item.tag == 1 ? code : targetLanguage)
actions.showLanguages(Self.languages.map { (code: $0, name: languageName($0)) },
selected: source ? sourceLanguage : targetLanguage,
source: source, relativeTo: actionsButton)
translateButton.keyEquivalent = actions.isShown ? "" : "\r"
}
func setLanguages(source: String, target: String) {
+14 -83
View File
@@ -5,15 +5,21 @@ public struct Application: Identifiable, Sendable {
case translate
case emoji
case notes
case toggleTheme
public var presentsView: Bool {
switch self {
case .translate, .emoji, .notes: true
case .toggleTheme: false
}
}
}
public static let commands = Command.allCases.map { Application(command: $0) }
public let command: Command?
public var id: URL { url }
public let url: URL
public let name: String
fileprivate let searchableName: String
fileprivate let searchableCharacters: [Character]
fileprivate let searchableWordStarts: [Int]
fileprivate let searchableText: FuzzySearchText
public init(url: URL, name: String) {
self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name)
@@ -24,6 +30,7 @@ public struct Application: Identifiable, Sendable {
case .translate: "Conjure Translate"
case .emoji: "Conjure Emoji"
case .notes: "Conjure Notes"
case .toggleTheme: "Toggle System Theme"
}
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command)
}
@@ -33,17 +40,7 @@ public struct Application: Identifiable, Sendable {
url = command != nil || canonicalURL.hasDirectoryPath
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
self.name = name
searchableName = folded(name)
let characters = Array(searchableName)
searchableCharacters = characters
var starts: [Int] = []
var previousWasWordCharacter = false
for index in characters.indices {
let isWordCharacter = characters[index].isLetter || characters[index].isNumber
if isWordCharacter && !previousWasWordCharacter { starts.append(index) }
previousWasWordCharacter = isWordCharacter
}
searchableWordStarts = starts
searchableText = FuzzySearchText(name)
}
}
@@ -114,83 +111,17 @@ public enum ApplicationSearch {
for query: String, in applications: [Application],
breakingTiesWith preferred: (Application, Application) -> Bool
) -> [Application] {
let query = folded(query.trimmingCharacters(in: .whitespacesAndNewlines))
guard !query.isEmpty else { return applications.sorted(by: preferred) }
let characters = Array(query)
var ranked = applications.compactMap { application -> (Application, Rank)? in
let name = application.searchableName
if name == query { return (application, Rank(category: 0)) }
if name.hasPrefix(query) { return (application, Rank(category: 1)) }
if application.searchableWordStarts.contains(where: {
$0 > 0 && application.searchableCharacters[$0...].starts(with: characters)
}) {
return (application, Rank(category: 2))
}
if matchesWordInitials(characters, in: application) {
return (application, Rank(category: 3))
}
if name.contains(query) { return (application, Rank(category: 4)) }
guard let rank = fuzzyRank(characters, in: application.searchableCharacters) else { return nil }
return (application, rank)
}
ranked.sort { lhs, rhs in
if lhs.1 != rhs.1 { return lhs.1 < rhs.1 }
return preferred(lhs.0, rhs.0)
}
return ranked.map(\.0)
}
private struct Rank: Comparable {
let category: Int
var gaps: Int = 0
var start: Int = 0
static func < (lhs: Rank, rhs: Rank) -> Bool {
(lhs.category, lhs.gaps, lhs.start) < (rhs.category, rhs.gaps, rhs.start)
}
}
private static func matchesWordInitials(_ query: [Character], in application: Application) -> Bool {
var matched = 0
for start in application.searchableWordStarts {
guard application.searchableCharacters[start] == query[matched] else { continue }
matched += 1
if matched == query.count { return true }
}
return false
}
// ponytail: quadratic in app-name length; use a linear matcher if long command text becomes searchable.
private static func fuzzyRank(_ query: [Character], in name: [Character]) -> Rank? {
guard query.count <= name.count else { return nil }
var best: Rank?
for start in name.indices where name[start] == query[0] {
var matched = 1
var end = start
while matched < query.count && end + 1 < name.count {
end += 1
if name[end] == query[matched] { matched += 1 }
}
guard matched == query.count else { continue }
let rank = Rank(category: 5, gaps: end - start + 1 - query.count, start: start)
if let best, best <= rank { continue }
best = rank
}
return best
FuzzySearchText.results(
for: query, in: applications, text: { $0.searchableText }, breakingTiesWith: preferred
)
}
}
private let searchLocale = Locale(identifier: "en_US_POSIX")
private func nonempty(_ name: String?) -> String? {
guard let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return name
}
private func folded(_ string: String) -> String {
string.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale)
}
func alphabetical(_ lhs: Application, _ rhs: Application) -> Bool {
let order = lhs.name.localizedStandardCompare(rhs.name)
if order != .orderedSame { return order == .orderedAscending }
+98
View File
@@ -0,0 +1,98 @@
import Foundation
public struct FuzzySearchText: Sendable {
private let foldedText: String
private let characters: [Character]
private let wordStarts: [Int]
public init(_ text: String) {
foldedText = Self.folded(text)
let characters = Array(foldedText)
self.characters = characters
var starts: [Int] = []
var previousWasWordCharacter = false
for index in characters.indices {
let isWordCharacter = characters[index].isLetter || characters[index].isNumber
if isWordCharacter && !previousWasWordCharacter { starts.append(index) }
previousWasWordCharacter = isWordCharacter
}
wordStarts = starts
}
public static func results<Value>(
for query: String, in values: [Value],
text: (Value) -> FuzzySearchText,
breakingTiesWith preferred: (Value, Value) -> Bool
) -> [Value] {
let query = folded(query.trimmingCharacters(in: .whitespacesAndNewlines))
guard !query.isEmpty else { return values.sorted(by: preferred) }
let characters = Array(query)
var ranked = values.compactMap { value -> (Value, Rank)? in
let searchable = text(value)
let name = searchable.foldedText
if name == query { return (value, Rank(category: 0)) }
if name.hasPrefix(query) { return (value, Rank(category: 1)) }
if searchable.wordStarts.contains(where: {
$0 > 0 && searchable.characters[$0...].starts(with: characters)
}) {
return (value, Rank(category: 2))
}
if matchesWordInitials(characters, in: searchable) {
return (value, Rank(category: 3))
}
if name.contains(query) { return (value, Rank(category: 4)) }
guard let rank = fuzzyRank(characters, in: searchable.characters) else { return nil }
return (value, rank)
}
ranked.sort { lhs, rhs in
if lhs.1 != rhs.1 { return lhs.1 < rhs.1 }
return preferred(lhs.0, rhs.0)
}
return ranked.map(\.0)
}
private struct Rank: Comparable {
let category: Int
var gaps: Int = 0
var start: Int = 0
static func < (lhs: Rank, rhs: Rank) -> Bool {
(lhs.category, lhs.gaps, lhs.start) < (rhs.category, rhs.gaps, rhs.start)
}
}
private static func matchesWordInitials(_ query: [Character], in text: FuzzySearchText) -> Bool {
var matched = 0
for start in text.wordStarts {
guard text.characters[start] == query[matched] else { continue }
matched += 1
if matched == query.count { return true }
}
return false
}
// ponytail: quadratic in text length; use a linear matcher if long text becomes searchable.
private static func fuzzyRank(_ query: [Character], in name: [Character]) -> Rank? {
guard query.count <= name.count else { return nil }
var best: Rank?
for start in name.indices where name[start] == query[0] {
var matched = 1
var end = start
while matched < query.count && end + 1 < name.count {
end += 1
if name[end] == query[matched] { matched += 1 }
}
guard matched == query.count else { continue }
let rank = Rank(category: 5, gaps: end - start + 1 - query.count, start: start)
if let best, best <= rank { continue }
best = rank
}
return best
}
private static let searchLocale = Locale(identifier: "en_US_POSIX")
private static func folded(_ string: String) -> String {
string.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale)
}
}
@@ -53,7 +53,7 @@ import Testing
let content = try #require(launcher.window?.contentView)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
search.stringValue = "ct"
search.stringValue = command.name
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
+1 -1
View File
@@ -2,7 +2,7 @@ import AppKit
import Testing
@testable import Conjure
@Test(arguments: ["emoji", "notes", "translate"])
@Test(arguments: ["emoji", "notes", "translate", "theme"])
@MainActor func commandArtworkHasBalancedVisiblePadding(query: String) throws {
_ = NSApplication.shared
let launcher = LauncherWindowController()
@@ -0,0 +1,121 @@
import AppKit
import Testing
@testable import Conjure
@MainActor
private func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
@Suite(.serialized)
struct LanguagePickerTests {
@Test @MainActor
func translationActionsFuzzySelectBothLanguagesAndKeepCancellationLocal() throws {
_ = NSApplication.shared
let suite = "LanguagePicker.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let tool = TranslationController(defaults: defaults,
myMemory: { _, _, _ in Issue.record("Selecting a language must not translate"); return "unexpected" })
tool.input = { "Text stays unchanged" }
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: .borderless, backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = tool.view
defer { tool.close(); window.orderOut(nil) }
let host = try #require(window.contentView)
var closed = false
tool.closed = { closed = true }
var focused = false
tool.focusSearch = { focused = true }
func search(_ label: String) throws -> NSSearchField {
try #require(descendants(host).compactMap { $0 as? NSSearchField }.first { $0.accessibilityLabel() == label })
}
func filter(_ field: NSSearchField, _ query: String) {
field.stringValue = query
field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field))
host.layoutSubtreeIfNeeded()
}
func openLanguages(source: Bool) throws -> NSSearchField {
tool.showActions()
filter(try search("Search translation actions"), source ? "source" : "target")
#expect(tool.handle(#selector(NSResponder.insertNewline(_:))))
return try search(source ? "Search source languages" : "Search target languages")
}
let source = try openLanguages(source: true)
filter(source, "jpns")
#expect(tool.handle(#selector(NSResponder.insertNewline(_:))))
#expect(tool.sourceLanguage == "ja")
#expect(tool.targetLanguage == "ja")
#expect(focused)
#expect(!closed)
let target = try openLanguages(source: false)
filter(target, "grmn")
let german = try #require(descendants(host).compactMap { $0 as? NSButton }.first { $0.title == "German" })
german.performClick(nil)
#expect(tool.targetLanguage == "de")
#expect(defaults.string(forKey: "translationSourceLanguage") == "ja")
#expect(defaults.string(forKey: "translationTargetLanguage") == "de")
let cancelled = try openLanguages(source: false)
filter(cancelled, "zzzzzzzz")
#expect(tool.handle(#selector(NSResponder.insertNewline(_:))))
#expect(tool.targetLanguage == "de")
#expect(cancelled.window === window)
#expect(tool.handle(#selector(NSResponder.cancelOperation(_:))))
#expect(!closed)
#expect(tool.input?() == "Text stays unchanged")
let reopened = TranslationController(defaults: defaults)
#expect(reopened.sourceLanguage == "ja")
#expect(reopened.targetLanguage == "de")
}
@Test @MainActor
func languageListScrollsToCurrentSelectionAndNavigatesBeyondVisibleRows() 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) }
let choices = (0..<20).map { (code: "\($0)", name: String(format: "Language %02d", $0)) }
var selected: String?
popup.selectLanguage = { selected = $0 }
popup.showLanguages(choices, selected: "15", source: true, relativeTo: anchor)
host.layoutSubtreeIfNeeded()
let table = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first)
#expect(table.selectedRow == 15)
#expect(NSLocationInRange(table.selectedRow, table.rows(in: table.visibleRect)))
#expect(table.visibleRect.height < table.bounds.height)
popup.moveSelection(by: -10)
#expect(table.selectedRow == 5)
#expect(NSLocationInRange(table.selectedRow, table.rows(in: table.visibleRect)))
#expect(popup.handle(#selector(NSResponder.insertNewline(_:))))
#expect(selected == "5")
popup.close(animated: false)
#expect(!pill.isAccessibilityHidden())
popup.showLanguages(choices, selected: "15", source: true, relativeTo: anchor)
let search = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first)
let filteredTable = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first)
// Shrink from a scrolled list to one result, then grow and shrink again.
for query in ["Language 19", "", "no matching language", "Language 03"] {
search.stringValue = query
search.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: search))
host.layoutSubtreeIfNeeded()
let count = filteredTable.numberOfRows
if count > 0 {
let row = filteredTable.rect(ofRow: filteredTable.selectedRow)
#expect(filteredTable.visibleRect.contains(row))
let viewport = try #require(filteredTable.enclosingScrollView)
#expect(viewport.contentSize.height >= min(CGFloat(count), 6) * filteredTable.rowHeight)
}
}
}
}
@@ -33,7 +33,7 @@ import Testing
#expect(search.accessibilityLabel() == "Search applications, calculate, or convert units and currencies")
}
@Test(arguments: Application.Command.allCases, [true, false])
@Test(arguments: Application.Command.allCases.filter(\.presentsView), [true, false])
@MainActor func commandsAreFullyVisibleImmediately(_ command: Application.Command, _ fromShortcut: Bool) throws {
_ = NSApplication.shared
let suite = "ConjurePresentation.\(UUID().uuidString)"
@@ -65,6 +65,7 @@ import Testing
case .emoji: $0 is NSCollectionView
case .translate: $0.accessibilityLabel() == "Japanese translation"
case .notes: $0.accessibilityLabel() == "Note editor"
case .toggleTheme: false
}
}
})
@@ -0,0 +1,114 @@
import AppKit
import ConjureKit
import Testing
@testable import Conjure
@Test(.serialized, arguments: [true, false]) @MainActor
func themeToggleDismissesOnlyOnSuccessAndPreventsOverlappingRequests(_ succeeds: Bool) async throws {
_ = NSApplication.shared
let suite = "ConjureTheme.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let history = LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) })
let theme = Application(command: .toggleTheme)
let competitor = Application(url: URL(fileURLWithPath: "/Applications/Zebra.app"), name: "Zebra")
let entries = [theme, competitor]
history.recordLaunch(of: competitor)
var pending: CheckedContinuation<Void, any Error>?
var requests = 0
let launcher = LauncherWindowController(history: history, defaults: defaults, toggleSystemTheme: {
requests += 1
try await withCheckedThrowingContinuation { pending = $0 }
})
let window = try #require(launcher.window)
defer {
if let sheet = window.attachedSheet {
window.endSheet(sheet)
sheet.orderOut(nil)
}
window.orderOut(nil)
}
// Exercise search and dismissal without activating the test host or another running app.
window.orderFront(nil)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let content = try #require(window.contentView)
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
search.stringValue = "theme"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
try await waitForTheme("Request started") { pending != nil }
#expect(window.isVisible)
#expect(history.suggestions(in: entries).first?.id == competitor.id)
launcher.toggle(command: .toggleTheme)
#expect(requests == 1)
let continuation = try #require(pending)
if succeeds {
continuation.resume()
try await waitForTheme("Success dismissed launcher") { !window.isVisible }
#expect(history.suggestions(in: entries).first?.id == theme.id)
} else {
continuation.resume(throwing: NSError(domain: "ThemeTest", code: -1743,
userInfo: [NSLocalizedDescriptionKey: "Automation permission denied"]))
try await waitForTheme("Failure showed alert") { window.attachedSheet != nil }
#expect(window.isVisible)
#expect(history.suggestions(in: entries).first?.id == competitor.id)
let sheet = try #require(window.attachedSheet)
window.endSheet(sheet)
sheet.orderOut(nil)
try await waitForTheme("Failure alert closed") { window.attachedSheet == nil }
}
}
@MainActor private final class ThemeWindowEvents {
var becameKey = false
}
@Test(.serialized, arguments: [true, false], [true, false]) @MainActor
func actionActivationPreservesWindowVisibilityAndSearch(_ directPresentation: Bool, _ initiallyVisible: Bool) async throws {
_ = NSApplication.shared
let suite = "ConjureBackgroundTheme.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let history = LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) })
let theme = Application(command: .toggleTheme)
let competitor = Application(url: URL(fileURLWithPath: "/Applications/Zebra.app"), name: "Zebra")
let entries = [theme, competitor]
history.recordLaunch(of: competitor)
var pending: CheckedContinuation<Void, Never>?
let launcher = LauncherWindowController(history: history, defaults: defaults, toggleSystemTheme: {
await withCheckedContinuation { pending = $0 }
})
let window = try #require(launcher.window)
defer { window.orderOut(nil) }
if initiallyVisible { window.orderFront(nil) }
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let content = try #require(window.contentView)
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
search.stringValue = "keep this search"
let events = ThemeWindowEvents()
let observer = NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main
) { _ in MainActor.assumeIsolated { events.becameKey = true } }
defer { NotificationCenter.default.removeObserver(observer) }
if directPresentation { launcher.present(command: .toggleTheme) }
else { launcher.toggle(command: .toggleTheme) }
#expect(window.isVisible == initiallyVisible)
try await waitForTheme("Background action started") { pending != nil }
#expect(window.isVisible == initiallyVisible)
#expect(search.stringValue == "keep this search")
#expect(history.suggestions(in: entries).first?.id == competitor.id)
pending?.resume()
try await waitForTheme("Background action completed") { history.suggestions(in: entries).first?.id == theme.id }
#expect(window.isVisible == initiallyVisible)
#expect(search.stringValue == "keep this search")
#expect(!events.becameKey)
}
@MainActor private func waitForTheme(_ state: Comment, _ condition: () -> Bool) async throws {
// Scheduling deadline only; launch ranking uses the fixed clock above.
for _ in 0..<1_000 {
if condition() { return }
try await Task.sleep(for: .milliseconds(1))
}
try #require(condition(), state)
}