Compare commits
5 Commits
cd47828ddb
...
fcd2d1229f
| Author | SHA1 | Date | |
|---|---|---|---|
| fcd2d1229f | |||
| 0074c88e5d | |||
| a68694e66b | |||
| cead6d9382 | |||
| fa3591adf8 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user