diff --git a/README.md b/README.md index d81ba4b..c1fa78a 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ The launcher, emoji picker, notes, and translation tool open immediately without | Open a result or copy a calculated answer | Return or double-click | | Open actions for the selected entry | Command-K | | Open one of the first nine results | Hold Command until numbers appear, then press 1–9 | -| Dismiss the launcher, including the current Command and Actions | Escape or Command-W | +| Close the open action/provider picker, otherwise dismiss the launcher | Escape | +| Dismiss the launcher, including the current Command and pickers | Command-W | +| Open the translation provider picker | Command-P (in Translate) | | Go back without dismissing | Back chevron | | Open Settings | Command-comma | | Quit | Command-Q | @@ -114,7 +116,7 @@ If macOS keeps prompting despite an enabled Accessibility entry, quit Conjure, r Open **Actions** with Command-K for **Copy and Paste** (default), **Copy Emoji** (copy only, no Accessibility permission needed), **Favorite**, or **Remove Favorite**. Favorites are saved locally and grouped ahead of other emoji, with a small extra spacing break when browsing without a search. There are no star badges. -With Actions open, the Back chevron closes Actions first; another Back returns to Applications and restores your search. Escape or Command-W dismisses the entire launcher, including the current Command and its Actions. Switching destinations also removes the previous Command's Actions completely. Copy and Copy and Paste dismiss the launcher without an intermediate Back step. +With Actions open, Escape or the Back chevron closes Actions first. Another Escape dismisses the launcher; another Back returns to Applications and restores your search. Command-W always dismisses the entire launcher, including the current Command and its Actions. Switching destinations also removes the previous Command's Actions completely. Copy and Copy and Paste dismiss the launcher without an intermediate Back step. 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. @@ -132,9 +134,9 @@ Notes autosave locally as RTFD packages, including embedded images, under `~/Lib Search for **Conjure Translate**, identified by its centered 🌏 icon, and open it. Choose a provider in the pill-shaped header selector, enter text in the search bar, then press Return or click **Translate**. Typing alone never sends text. When the translation is available, the action changes to **Copy**: press Return or click it to copy the full translation and close Conjure. Command-C still copies selected output. Editing the input restores **Translate**. -The provider selector expands into 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; Back or clicking outside closes the popup. Changing providers clears the previous result without submitting a request. +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. -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. Back closes Actions first, then returns to the launcher and restores your search; Escape or Command-W 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. 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 to the launcher and restores your search. Command-W always dismisses the entire window. 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. diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index 32f5a94..afce637 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -308,6 +308,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat func handle(_ selector: Selector) -> Bool { switch selector { + case #selector(NSResponder.cancelOperation(_:)): + guard isShown else { return false } + close() case #selector(NSResponder.insertNewline(_:)): if let selectedEntry { chooseAction(at: selectedEntry) } case #selector(NSResponder.moveUp(_:)): moveSelection(by: -1) diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 55f4f5f..33c2098 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -97,6 +97,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe } window.usesTextNavigation = { [weak self] in self?.notes != nil } window.dismiss = { [weak self] in self?.dismiss() } + window.escape = { [weak self] in self?.handleEscape() } window.canUseNumberShortcuts = { [weak self] in guard let self else { return false } return !self.isLaunching && self.calculation == nil && self.resultCount > 0 @@ -115,8 +116,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe actions.closed = { [weak self] in guard let self else { return } self.openButton.keyEquivalent = "\r" - if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) } - else { self.dismiss(restoringFocus: false) } + self.window?.makeFirstResponder(self.searchField) } currency.changed = { [weak self] in self?.updateResults(preservingSelection: true) } restorePosition() @@ -494,9 +494,15 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe updateResults() } + private func handleEscape() { + let selector = #selector(NSResponder.cancelOperation(_:)) + if actions.handle(selector) || translation?.handle(selector) == true || emojiPicker?.handle(selector) == true { return } + dismiss() + } + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSResponder.cancelOperation(_:)) { - dismiss() + handleEscape() return true } if let emojiPicker { return emojiPicker.handle(commandSelector) } @@ -1163,6 +1169,7 @@ final class LauncherPanel: NSPanel { override var canBecomeMain: Bool { true } var navigateSelection: ((Int) -> Void)? var dismiss: (() -> Void)? + var escape: (() -> Void)? var dragEnded: (() -> Void)? var canUseNumberShortcuts: (() -> Bool)? var numberHintsChanged: (() -> Void)? @@ -1293,7 +1300,7 @@ final class LauncherPanel: NSPanel { } override func cancelOperation(_ sender: Any?) { - dismiss?() + if let escape { escape() } else { dismiss?() } } override func performClose(_ sender: Any?) { diff --git a/Sources/Conjure/TranslationController.swift b/Sources/Conjure/TranslationController.swift index 3f0cba5..d6dfe0a 100644 --- a/Sources/Conjure/TranslationController.swift +++ b/Sources/Conjure/TranslationController.swift @@ -68,6 +68,9 @@ final class TranslationController: NSViewController { providerButton.setAccessibilityLabel("Translation provider") providerButton.target = self providerButton.action = #selector(showProviders) + providerButton.keyEquivalent = "p" + providerButton.keyEquivalentModifierMask = .command + providerButton.toolTip = "Choose translation provider (Command-P)" providerButton.isBordered = false providerButton.font = .systemFont(ofSize: 12, weight: .medium) providerButton.contentTintColor = SunsetPalette.text diff --git a/Sources/ConjureKit/GlobalShortcuts.swift b/Sources/ConjureKit/GlobalShortcuts.swift index 2a3c639..05c1e18 100644 --- a/Sources/ConjureKit/GlobalShortcuts.swift +++ b/Sources/ConjureKit/GlobalShortcuts.swift @@ -124,6 +124,7 @@ public final class GlobalShortcuts { case "z": return "Undo" case "m": return "Minimize" case "k": return "entry actions" + case "p": return "translation provider selection" case "return", "enter": return "Open, Copy, Translate, or confirm uninstall" case "1"..."9" where key.utf8.count == 1: return "numbered application launching" default: break diff --git a/Tests/ConjureKitTests/EscapeDismissalTests.swift b/Tests/ConjureKitTests/EscapeDismissalTests.swift index 5dfcfd0..54c4980 100644 --- a/Tests/ConjureKitTests/EscapeDismissalTests.swift +++ b/Tests/ConjureKitTests/EscapeDismissalTests.swift @@ -3,8 +3,8 @@ import ConjureKit import Testing @testable import Conjure -@Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "notes-editor"]) -@MainActor func escapeDismissesEntireLauncher(_ destination: String) throws { +@Test(arguments: ["launcher", "launcher-actions", "emoji", "emoji-actions", "translate-input", "translate-output", "translate-actions", "translate-providers", "notes-editor"]) +@MainActor func escapeClosesPickerBeforeDismissingLauncher(_ destination: String) throws { _ = NSApplication.shared let suite = "ConjureEscape.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -42,6 +42,17 @@ import Testing $0.title == "Actions" && $0.target is EmojiPickerController }) (button.target as? EmojiPickerController)?.showActions() + } else if destination == "translate-actions" { + let button = try #require(descendants(content).compactMap { $0 as? NSButton }.first { + $0.title == "Actions" && $0.target is TranslationController + }) + button.performClick(nil) + } else if destination == "translate-providers" { + let shortcut = try #require(NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: .command, timestamp: 1, + windowNumber: window.windowNumber, context: nil, characters: "p", + charactersIgnoringModifiers: "p", isARepeat: false, keyCode: 35)) + #expect(window.performKeyEquivalent(with: shortcut)) } else if destination == "translate-output" { let output = try #require(descendants(content).compactMap { $0 as? NSTextView }.first { $0.accessibilityLabel() == "Japanese translation" @@ -57,6 +68,11 @@ import Testing windowNumber: window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)) window.sendEvent(escape) + if destination.hasSuffix("-actions") || destination == "translate-providers" { + #expect(window.isVisible) + #expect(window.firstResponder === search.currentEditor()) + window.sendEvent(escape) + } #expect(!window.isVisible) #expect(!descendants(content).contains { $0 is NSCollectionView }) }