feat: add system theme toggle command
This commit is contained in:
@@ -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 **Conjure Toggle Light/Dark 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. Command shortcuts open their selected tool directly; the theme shortcut switches the system appearance immediately and dismisses Conjure on success. Pressing a tool 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.
|
||||
|
||||
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,12 @@ 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 **Conjure Toggle Light/Dark Theme** (try `theme`, `light`, or `dark`) and press Return to switch macOS from light to dark appearance or back. It runs immediately, without opening a tool view, and dismisses Conjure on success. You can favorite it or assign a global shortcut in Settings.
|
||||
|
||||
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 stays open and shows the failure. Conjure's own launcher keeps its existing dark styling.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,6 +38,7 @@ 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)?
|
||||
@@ -59,12 +60,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],
|
||||
@@ -145,7 +148,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
updateResults()
|
||||
if let command {
|
||||
showCommand(command)
|
||||
history.recordLaunch(of: Application(command: command))
|
||||
}
|
||||
window?.deminiaturize(nil)
|
||||
showWindow(nil)
|
||||
@@ -558,11 +560,14 @@ 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 = NSImage(systemSymbolName: "circle.lefthalf.filled", accessibilityDescription: nil)
|
||||
cell.toolTip = "Switch macOS between light and dark appearance"
|
||||
default:
|
||||
cell.imageView?.image = Self.translateIcon
|
||||
cell.toolTip = "Translate English to Japanese"
|
||||
}
|
||||
cell.imageView?.contentTintColor = nil
|
||||
cell.imageView?.contentTintColor = entry.command == .toggleTheme ? SunsetPalette.accent : nil
|
||||
} else {
|
||||
let application = results[row]
|
||||
cell.textField?.stringValue = application.name
|
||||
@@ -734,7 +739,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
||||
if let command = application.command {
|
||||
showCommand(command)
|
||||
history.recordLaunch(of: application)
|
||||
return
|
||||
}
|
||||
(window as? LauncherPanel)?.resetNumberHints()
|
||||
@@ -800,9 +804,28 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
return true
|
||||
}
|
||||
|
||||
private func toggleTheme() {
|
||||
actions.close(animated: false)
|
||||
isLaunching = true
|
||||
updateOpenButton()
|
||||
Task {
|
||||
do {
|
||||
try await toggleSystemTheme()
|
||||
history.recordLaunch(of: Application(command: .toggleTheme))
|
||||
isLaunching = false
|
||||
dismiss()
|
||||
} catch {
|
||||
isLaunching = false
|
||||
let alert = NSAlert(error: error)
|
||||
alert.messageText = "Couldn’t toggle system theme"
|
||||
if let window { await alert.beginSheetModal(for: window) }
|
||||
}
|
||||
updateOpenButton()
|
||||
}
|
||||
}
|
||||
|
||||
private func showCommand(_ command: Application.Command) {
|
||||
guard !isCommandOpen, let content = window?.contentView else { return }
|
||||
activeCommand = command
|
||||
let controller: NSViewController
|
||||
let placeholder: String
|
||||
let accessibilityLabel: String
|
||||
@@ -841,7 +864,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
controller = tool
|
||||
placeholder = "Conjure Notes"
|
||||
accessibilityLabel = "Conjure Notes"
|
||||
case .toggleTheme:
|
||||
toggleTheme()
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ public struct Application: Identifiable, Sendable {
|
||||
case translate
|
||||
case emoji
|
||||
case notes
|
||||
case toggleTheme
|
||||
}
|
||||
public static let commands = Command.allCases.map { Application(command: $0) }
|
||||
public let command: Command?
|
||||
@@ -22,6 +23,7 @@ public struct Application: Identifiable, Sendable {
|
||||
case .translate: "Conjure Translate"
|
||||
case .emoji: "Conjure Emoji"
|
||||
case .notes: "Conjure Notes"
|
||||
case .toggleTheme: "Conjure Toggle Light/Dark Theme"
|
||||
}
|
||||
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.translate, .emoji, .notes], [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,69 @@
|
||||
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 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