diff --git a/Shared/Utilities/AXHelpers.swift b/Shared/Utilities/AXHelpers.swift index 6461d6c2..f9487048 100644 --- a/Shared/Utilities/AXHelpers.swift +++ b/Shared/Utilities/AXHelpers.swift @@ -56,4 +56,19 @@ enum AXHelpers { return result == .success ? pid : nil } } + + /// Performs the press action on the given element, returning whether it + /// succeeded. Used to open the menus of Electron/Chromium tray items, which + /// ignore synthetic mouse clicks. + @discardableResult + static func press(_ element: UIElement) -> Bool { + queue.sync { + do { + try element.performAction(.press) + return true + } catch { + return false + } + } + } } diff --git a/Thaw/Hotkeys/Hotkey.swift b/Thaw/Hotkeys/Hotkey.swift index 3b9da7cc..eba85930 100644 --- a/Thaw/Hotkeys/Hotkey.swift +++ b/Thaw/Hotkeys/Hotkey.swift @@ -96,6 +96,11 @@ extension Hotkey { profileManager.applyProfile(profile, to: appState, previousProfileID: previousID) } } + } else if hotkey.action == .openMenuBarItem { + let key = ObjectIdentifier(hotkey) + if let identifier = appState.menuBarManager.hotkeyItemMap[key] { + appState.menuBarManager.openItem(withIdentifier: identifier) + } } else { hotkey.action.perform(appState: appState) } diff --git a/Thaw/Hotkeys/HotkeyAction.swift b/Thaw/Hotkeys/HotkeyAction.swift index c111e761..2a77560b 100644 --- a/Thaw/Hotkeys/HotkeyAction.swift +++ b/Thaw/Hotkeys/HotkeyAction.swift @@ -18,12 +18,17 @@ enum HotkeyAction: String, Codable, CaseIterable { case enableIceBar = "EnableIceBar" case toggleApplicationMenus = "ToggleApplicationMenus" - /// Used by profile hotkeys — action is handled externally. + /// Used by profile hotkeys, action is handled externally. case profileApply = "ProfileApply" - /// Actions that should appear in the Hotkeys settings pane. + /// Used by per-item hotkeys, action is handled externally. + case openMenuBarItem = "OpenMenuBarItem" + + /// Actions that should appear in the Hotkeys settings pane as fixed, + /// singleton recorders. Dynamic per-profile and per-item hotkeys are + /// created separately and are excluded here. static var settingsActions: [HotkeyAction] { - allCases.filter { $0 != .profileApply } + allCases.filter { $0 != .profileApply && $0 != .openMenuBarItem } } @MainActor @@ -56,6 +61,9 @@ enum HotkeyAction: String, Codable, CaseIterable { case .profileApply: // Handled externally by ProfileManager's custom registration. break + case .openMenuBarItem: + // Handled externally by MenuBarManager's per-item registration. + break } } } diff --git a/Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift b/Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift index bbbe7fd5..fab7c4bc 100644 --- a/Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift +++ b/Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift @@ -121,6 +121,20 @@ final class MenuBarItemImageCache: ObservableObject, @unchecked Sendable { /// has accessed the layout settings. @Published private(set) var settingsPaneHasBeenOpened = false + /// Whether the per-item hotkey list in the Hotkeys settings pane is expanded. + /// While collapsed, the pane has no visible item-icon consumer, so the live + /// capture loop stays off rather than paying the off-screen SkyLight capture + /// cost for items the user cannot see. + @Published private(set) var isItemHotkeyListExpanded = false + + /// Updates isItemHotkeyListExpanded from the Hotkeys settings UI. + func setItemHotkeyListExpanded(_ expanded: Bool) { + guard isItemHotkeyListExpanded != expanded else { + return + } + isItemHotkeyListExpanded = expanded + } + deinit { memoryPressureSource?.cancel() currentUpdateTask?.cancel() @@ -353,6 +367,16 @@ final class MenuBarItemImageCache: ObservableObject, @unchecked Sendable { } .store(in: &c) + // Start/stop the live refresh when the Hotkeys pane's per-item list + // is expanded or collapsed, since that gates its capture consumer. + $isItemHotkeyListExpanded + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.startLiveRefreshIfNeeded() + } + .store(in: &c) + // Restart the live refresh loop when the icon refresh interval changes appState.settings.advanced.$iconRefreshInterval .removeDuplicates() @@ -378,6 +402,7 @@ final class MenuBarItemImageCache: ObservableObject, @unchecked Sendable { let isAppFrontmost: Bool let isSettingsPresented: Bool let settingsNavigationIdentifier: SettingsNavigationIdentifier? + let isItemHotkeyListExpanded: Bool } /// Constructs a NavigationStateSnapshot from the current appState in a single MainActor hop. @@ -390,7 +415,8 @@ final class MenuBarItemImageCache: ObservableObject, @unchecked Sendable { isSearchPresented: false, isAppFrontmost: false, isSettingsPresented: false, - settingsNavigationIdentifier: nil + settingsNavigationIdentifier: nil, + isItemHotkeyListExpanded: false ) } return NavigationStateSnapshot( @@ -398,7 +424,8 @@ final class MenuBarItemImageCache: ObservableObject, @unchecked Sendable { isSearchPresented: appState.navigationState.isSearchPresented, isAppFrontmost: appState.navigationState.isAppFrontmost, isSettingsPresented: appState.navigationState.isSettingsPresented, - settingsNavigationIdentifier: appState.navigationState.settingsNavigationIdentifier + settingsNavigationIdentifier: appState.navigationState.settingsNavigationIdentifier, + isItemHotkeyListExpanded: isItemHotkeyListExpanded ) } @@ -441,7 +468,20 @@ final class MenuBarItemImageCache: ObservableObject, @unchecked Sendable { return true } - return nav.isAppFrontmost && nav.isSettingsPresented && nav.settingsNavigationIdentifier == .menuBarLayout + guard nav.isAppFrontmost, nav.isSettingsPresented else { + return false + } + switch nav.settingsNavigationIdentifier { + case .menuBarLayout: + return true + case .hotkeys: + // Only the expanded per-item hotkey list consumes item icons. Read + // from the snapshot so this stays race-free when called off the main + // actor (e.g. from refreshVisibleConsumersOrPrewarmLayoutCache). + return nav.isItemHotkeyListExpanded + default: + return false + } } /// Convenience overload that reads current state on MainActor when no snapshot is provided. @@ -455,7 +495,8 @@ final class MenuBarItemImageCache: ObservableObject, @unchecked Sendable { isSearchPresented: appState.navigationState.isSearchPresented, isAppFrontmost: appState.navigationState.isAppFrontmost, isSettingsPresented: appState.navigationState.isSettingsPresented, - settingsNavigationIdentifier: appState.navigationState.settingsNavigationIdentifier + settingsNavigationIdentifier: appState.navigationState.settingsNavigationIdentifier, + isItemHotkeyListExpanded: isItemHotkeyListExpanded ) return hasVisibleCaptureConsumer(nav: nav) } @@ -533,9 +574,14 @@ final class MenuBarItemImageCache: ObservableObject, @unchecked Sendable { // Determine which sections to refresh based on what's visible let sections: [MenuBarSection.Name] - if nav.isSearchPresented - || (nav.isSettingsPresented && nav.settingsNavigationIdentifier == .menuBarLayout) - { + let isLayoutPane = nav.isSettingsPresented + && nav.settingsNavigationIdentifier == .menuBarLayout + // The Hotkeys pane only needs item icons while its per-item list + // disclosure is expanded. + let isHotkeyListVisible = nav.isSettingsPresented + && nav.settingsNavigationIdentifier == .hotkeys + && isItemHotkeyListExpanded + if nav.isSearchPresented || isLayoutPane || isHotkeyListVisible { sections = MenuBarSection.Name.allCases } else if nav.isIceBarPresented, let current = appState.menuBarManager.iceBarPanel.currentSection diff --git a/Thaw/MenuBar/MenuBarItems/MenuBarItemManager.swift b/Thaw/MenuBar/MenuBarItems/MenuBarItemManager.swift index ca434fad..306a9a10 100644 --- a/Thaw/MenuBar/MenuBarItems/MenuBarItemManager.swift +++ b/Thaw/MenuBar/MenuBarItems/MenuBarItemManager.swift @@ -6,6 +6,7 @@ // Copyright (Thaw) © 2026 Toni Förster // Licensed under the GNU GPLv3 +@preconcurrency import AXSwift import Cocoa @preconcurrency import Combine @preconcurrency import CoreGraphics @@ -3520,6 +3521,101 @@ extension MenuBarItemManager { } } + /// Activates a menu bar item by opening its menu, choosing the correct + /// path based on whether the item is currently on screen. + /// + /// On-screen items are clicked in place. Off-screen items (in the hidden + /// or always-hidden section) are routed through temporarilyShow, which + /// moves, clicks, and rehides the item internally. + /// + /// - Parameters: + /// - item: The menu bar item to activate. + /// - displayID: The display whose menu bar hosts a temporary reveal for + /// off-screen items. + func activate(item: MenuBarItem, on displayID: CGDirectDisplayID?) async { + if Bridging.isWindowOnScreen(item.windowID) { + // Electron/Chromium tray items (e.g. Claude) ignore Thaw's synthetic + // mouse click, so open those via an Accessibility press. Every other + // app responds to the normal click, which also preserves its native + // open/close toggle and works with popover-style menus (e.g. Cap, + // Droppy) that a stray AX interaction would disturb. + if isElectronItem(item), pressItemViaAccessibility(item) { + MenuBarItemManager.diagLog.info("Activated \(item.logString) via AX press") + return + } + do { + try await click(item: item, with: .left) + } catch { + MenuBarItemManager.diagLog.error("Failed to activate \(item.logString): \(error)") + } + } else { + await temporarilyShow(item: item, clickingWith: .left, on: displayID) + } + } + + /// Returns whether the item's owning app is an Electron app, detected by the + /// presence of the bundled Electron framework. Such apps ignore synthetic + /// mouse clicks on their tray icon and must be opened via an AX press. + private func isElectronItem(_ item: MenuBarItem) -> Bool { + // Fall back to ownerPID so this works during startup before sourcePID + // has been resolved. + let pid = item.sourcePID ?? item.ownerPID + guard let bundleURL = NSRunningApplication(processIdentifier: pid)?.bundleURL else { + return false + } + let electronFramework = bundleURL.appendingPathComponent( + "Contents/Frameworks/Electron Framework.framework" + ) + return FileManager.default.fileExists(atPath: electronFramework.path) + } + + /// Attempts to open the item's menu by performing an Accessibility press on + /// its status item element. Returns false (so the caller can fall back to + /// a synthetic click) when the element cannot be resolved or the press fails. + private func pressItemViaAccessibility(_ item: MenuBarItem) -> Bool { + // Fall back to ownerPID so this works during startup before sourcePID + // has been resolved. + let pid = item.sourcePID ?? item.ownerPID + guard + let runningApp = NSRunningApplication(processIdentifier: pid), + let app = AXHelpers.application(for: runningApp), + let extrasMenuBar = AXHelpers.extrasMenuBar(for: app) + else { + return false + } + + let children = AXHelpers.children(for: extrasMenuBar) + guard !children.isEmpty else { + return false + } + + // A single status item is unambiguous. With several, match the one whose + // AX frame lines up with this item's window so the right menu opens. + let target: UIElement + if children.count == 1 { + target = children[0] + } else { + // Use the item's live window bounds so the nearest-child match is not + // thrown off by a stale cached position (which would make an Electron + // item fall back to the synthetic click it ignores). + let itemCenter = (Bridging.getWindowBounds(for: item.windowID) ?? item.bounds).center + guard + let best = children.min(by: { lhs, rhs in + let lhsDistance = AXHelpers.frame(for: lhs)?.center.distance(to: itemCenter) ?? .greatestFiniteMagnitude + let rhsDistance = AXHelpers.frame(for: rhs)?.center.distance(to: itemCenter) ?? .greatestFiniteMagnitude + return lhsDistance < rhsDistance + }), + let bestFrame = AXHelpers.frame(for: best), + bestFrame.center.distance(to: itemCenter) <= 10 + else { + return false + } + target = best + } + + return AXHelpers.press(target) + } + /// Clicks a menu bar item with the given mouse button. /// /// - Parameters: @@ -3652,6 +3748,20 @@ extension MenuBarItemManager { return current.isOnScreen } if let app = current.owningApplication { + // The captured window is the popup we just opened, so trust its + // on-screen state rather than requiring the app to be active in + // two cases the isActive check gets wrong: + // - Menu-bar agent apps (.accessory) can never report active, + // so their popover (e.g. BetterDisplay) would look hidden + // the instant it opens. + // - Some apps (e.g. Claude/Electron) place their menu at a + // non-standard window level, and it is our programmatic + // trigger, not the user, that opened it, so the app is + // not frontmost. A menu-sized window distinguishes this + // from an incidental small window. + if app.activationPolicy == .accessory || current.bounds.height > 40 { + return current.isOnScreen + } return app.isActive && current.isOnScreen } return current.isOnScreen @@ -3670,26 +3780,37 @@ extension MenuBarItemManager { } /// Checks whether the item's owning application has any visible - /// popup-menu window on screen. + /// menu window on screen. /// - /// Only matches the pop-up menu level (the level macOS uses for - /// menus opened from menu bar items). Status-level and main-menu - /// level windows are excluded because those are the menu bar items - /// themselves; including the temporarily-shown item we're - /// tracking; not popups created by clicking them. A liberal - /// "above normal" match was previously used as a catch-all, but - /// it matched floating panels, modal levels, and other unrelated - /// app windows, keeping `isShowingInterface` true indefinitely - /// and preventing rehide. + /// Matches the pop-up menu level (the level macOS uses for menus opened + /// from menu bar items). Some apps (e.g. DisplayLink) instead draw their + /// menu as a status- or main-menu-level window owned by the app rather + /// than at pop-up level, so those levels are also matched, but only when + /// the window is taller than a menu bar item, so the status item itself + /// (which sits in the menu bar) is not mistaken for an open menu. A + /// liberal "above normal" match was previously used as a catch-all, but + /// it matched floating panels, modal levels, and other unrelated app + /// windows, keeping `isShowingInterface` true indefinitely and + /// preventing rehide. private func appHasVisiblePopup() -> Bool { let windows = WindowInfo.createWindows(option: .onScreen) let popUpLevel = CGWindowLevelForKey(.popUpMenuWindow) + let statusLevel = CGWindowLevelForKey(.statusWindow) + let mainMenuLevel = CGWindowLevelForKey(.mainMenuWindow) return windows.contains { window in guard window.ownerPID == sourcePID else { return false } let level = CGWindowLevel(Int32(window.layer)) - return level == popUpLevel || level == popUpLevel - 1 + if level == popUpLevel || level == popUpLevel - 1 { + return true + } + // Menu bar items are at most ~menu-bar height; a real menu drawn + // at status/main-menu level is taller, which distinguishes it. + if level == statusLevel || level == mainMenuLevel { + return window.bounds.height > 40 + } + return false } } @@ -4073,34 +4194,42 @@ extension MenuBarItemManager { let idsBeforeClick = Set(Bridging.getWindowList(option: .onScreen)) let clickPID = clickItem.sourcePID ?? clickItem.ownerPID - do { - // Single attempt: the item is already at a known-good position with - // fresh bounds. If it fails, fall through to the fallback path below - // rather than spending 3× the semaphore timeout here. - try await click(item: clickItem, with: mouseButton, skipInputPause: true, maxAttempts: 1) - } catch { - MenuBarItemManager.diagLog.error("Error clicking item (first attempt): \(error); attempting fallback click") - - // Fallback: re-fetch the item from the live window list so the - // click targets a fresh MenuBarItem with current windowID and - // bounds, rather than the potentially stale pre-click struct. - let fallbackItems = await MenuBarItem.getMenuBarItems(on: resolvedDisplayID, option: .onScreen) - let fallbackItem = fallbackItems.first(where: { $0.windowID == clickItem.windowID }) ?? - fallbackItems.first(where: { - $0.tag.matchesIgnoringWindowID(clickItem.tag) && - ($0.sourcePID ?? $0.ownerPID) == (clickItem.sourcePID ?? clickItem.ownerPID) - }) ?? clickItem - - // We stay inside temporarilyShow so that idsBeforeClick and context - // remain in scope; shownInterfaceWindow can still be captured if - // the fallback succeeds, keeping isShowingInterface accurate for - // the rehide logic. + // Electron/Chromium tray items ignore the synthetic click, so open their + // menu via an Accessibility press once revealed, mirroring the on-screen + // path. Other apps (and right-clicks) use the synthetic click below. The + // popup window capture that follows is unaffected by which path opened it. + if mouseButton == .left, isElectronItem(clickItem), pressItemViaAccessibility(clickItem) { + MenuBarItemManager.diagLog.info("Activated \(clickItem.logString) via AX press") + } else { do { - try await click(item: fallbackItem, with: mouseButton, skipInputPause: true) + // Single attempt: the item is already at a known-good position with + // fresh bounds. If it fails, fall through to the fallback path below + // rather than spending 3× the semaphore timeout here. + try await click(item: clickItem, with: mouseButton, skipInputPause: true, maxAttempts: 1) } catch { - MenuBarItemManager.diagLog.error("Fallback click also failed for \(item.logString): \(error)") - // Icon is visible but both click attempts failed. - return .movedButClickFailed + MenuBarItemManager.diagLog.error("Error clicking item (first attempt): \(error); attempting fallback click") + + // Fallback: re-fetch the item from the live window list so the + // click targets a fresh MenuBarItem with current windowID and + // bounds, rather than the potentially stale pre-click struct. + let fallbackItems = await MenuBarItem.getMenuBarItems(on: resolvedDisplayID, option: .onScreen) + let fallbackItem = fallbackItems.first(where: { $0.windowID == clickItem.windowID }) ?? + fallbackItems.first(where: { + $0.tag.matchesIgnoringWindowID(clickItem.tag) && + ($0.sourcePID ?? $0.ownerPID) == (clickItem.sourcePID ?? clickItem.ownerPID) + }) ?? clickItem + + // We stay inside temporarilyShow so that idsBeforeClick and context + // remain in scope; shownInterfaceWindow can still be captured if + // the fallback succeeds, keeping isShowingInterface accurate for + // the rehide logic. + do { + try await click(item: fallbackItem, with: mouseButton, skipInputPause: true) + } catch { + MenuBarItemManager.diagLog.error("Fallback click also failed for \(item.logString): \(error)") + // Icon is visible but both click attempts failed. + return .movedButClickFailed + } } } diff --git a/Thaw/MenuBar/MenuBarManager.swift b/Thaw/MenuBar/MenuBarManager.swift index b4013cbb..c6aa7dd1 100644 --- a/Thaw/MenuBar/MenuBarManager.swift +++ b/Thaw/MenuBar/MenuBarManager.swift @@ -45,6 +45,19 @@ final class MenuBarManager: ObservableObject { /// Storage for internal observers. private var cancellables = Set() + /// Per-item hotkeys, keyed by MenuBarItem.uniqueIdentifier. Each opens the + /// item's menu when its key combination fires. Mirrors the per-profile + /// hotkeys on ProfileManager. + @Published private(set) var itemHotkeys: [String: Hotkey] = [:] + + /// Reverse map from a hotkey instance to the item identifier it opens. + /// Read by Hotkey.Listener when an openMenuBarItem hotkey fires. + var hotkeyItemMap: [ObjectIdentifier: String] = [:] + + /// Per-item hotkey persistence observers, keyed by item identifier so a + /// single binding can be torn down without disturbing the others. + private var itemHotkeyCancellables = [String: AnyCancellable]() + /// Cancellable for the periodic average-color refresh, active only while settings is visible. private var averageColorRefreshCancellable: AnyCancellable? @@ -101,6 +114,7 @@ final class MenuBarManager: ObservableObject { for section in sections { section.performSetup(with: appState) } + rebuildItemHotkeys() } /// Configures the internal observers for the manager. @@ -203,6 +217,17 @@ final class MenuBarManager: ObservableObject { self?.updateControlItemStates() } .store(in: &c) + + // Refresh per-item hotkeys when the set of menu bar items changes, + // so newly-arrived items become assignable. Debounced because the + // item cache ticks frequently and rebuilding on every tick would + // churn hotkey registrations. + appState.itemManager.$itemCache + .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + self?.rebuildItemHotkeys() + } + .store(in: &c) } $settingsWindow @@ -842,6 +867,105 @@ final class MenuBarManager: ObservableObject { func controlItem(withName name: MenuBarSection.Name) -> ControlItem? { section(withName: name)?.controlItem } + + // MARK: - Per-Item Hotkeys + + /// Creates and reconciles the per-item hotkeys, then observes their changes. + /// + /// Called during setup, whenever the item cache changes, and after a + /// profile is applied. Unlike the per-profile rebuild this is incremental: + /// existing hotkey instances are preserved so a frequent cache tick does + /// not tear down an in-use registration. A hotkey is created for every + /// item currently in the menu bar plus every identifier that still has a + /// saved binding (so a binding survives the owning app quitting), and is + /// dropped only when its identifier is neither present nor configured. + func rebuildItemHotkeys() { + guard let appState else { return } + + let saved = Defaults.dictionary(forKey: .menuBarItemHotkeys) as? [String: Data] ?? [:] + let dec = JSONDecoder() + let enc = JSONEncoder() + + // Only real, identifiable items are assignable: skip Thaw's own control + // items and items whose source app could not be resolved (their + // identifier is an unstable UUID). + let presentIdentifiers = Set( + appState.itemManager.itemCache.managedItems + .filter { !$0.isControlItem && $0.sourcePID != nil } + .map(\.uniqueIdentifier) + ) + let wantedIdentifiers = presentIdentifiers.union(saved.keys) + + var newHotkeys = itemHotkeys + + // Drop hotkeys for identifiers that are neither present nor configured. + for (identifier, hotkey) in itemHotkeys where !wantedIdentifiers.contains(identifier) { + hotkey.disable() + hotkeyItemMap[ObjectIdentifier(hotkey)] = nil + itemHotkeyCancellables[identifier] = nil + newHotkeys[identifier] = nil + } + + for identifier in wantedIdentifiers { + let savedCombo: KeyCombination? = saved[identifier].flatMap { data in + try? dec.decode(KeyCombination?.self, from: data) + } + + if let existing = newHotkeys[identifier] { + // Reconcile the live binding to the saved value (e.g. after a + // profile apply). Only assign when it actually differs so we + // avoid a redundant write back through the persistence sink. + if existing.keyCombination != savedCombo { + existing.keyCombination = savedCombo + } + continue + } + + let hotkey = Hotkey(action: .openMenuBarItem) + hotkey.performSetup(with: appState) + hotkey.keyCombination = savedCombo + hotkeyItemMap[ObjectIdentifier(hotkey)] = identifier + + // Observe future changes from HotkeyRecorder and persist them. + itemHotkeyCancellables[identifier] = hotkey.$keyCombination + .dropFirst() // Skip the initial value we just set. + .receive(on: DispatchQueue.main) + .sink { [weak self, weak hotkey] newCombo in + guard let self, let hotkey else { return } + var dict = Defaults.dictionary(forKey: .menuBarItemHotkeys) as? [String: Data] ?? [:] + if let combo = newCombo, let data = try? enc.encode(combo) { + dict[identifier] = data + } else { + dict.removeValue(forKey: identifier) + } + Defaults.set(dict, forKey: .menuBarItemHotkeys) + self.hotkeyItemMap[ObjectIdentifier(hotkey)] = newCombo != nil ? identifier : nil + } + + newHotkeys[identifier] = hotkey + } + + itemHotkeys = newHotkeys + } + + /// Opens the menu of the menu bar item with the given identifier. + /// + /// Resolves the live item from the current cache and routes it through the + /// shared activation path. No-ops if the item is not currently present + /// (e.g. its owning app has been quit). + func openItem(withIdentifier identifier: String) { + guard let appState else { return } + guard let item = appState.itemManager.itemCache.managedItems.first( + where: { $0.uniqueIdentifier == identifier } + ) else { + diagLog.info("Cannot open menu bar item; no live item for identifier \(identifier)") + return + } + let displayID = NSScreen.screenWithActiveMenuBar?.displayID + Task { + await appState.itemManager.activate(item: item, on: displayID) + } + } } // MARK: - MenuBarAverageColorInfo diff --git a/Thaw/MenuBar/Search/MenuBarSearchPanel.swift b/Thaw/MenuBar/Search/MenuBarSearchPanel.swift index 6722a389..e301753f 100644 --- a/Thaw/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Thaw/MenuBar/Search/MenuBarSearchPanel.swift @@ -723,17 +723,7 @@ private struct MenuBarSearchContentView: View { // the selected item. Uses KVO on isVisible so we resume as soon // as the panel hides rather than waiting a fixed 25 ms. await panel.waitUntilClosed(timeout: .milliseconds(200)) - if Bridging.isWindowOnScreen(item.windowID) { - try await itemManager.click(item: item, with: .left) - } else { - // temporarilyShow handles move, click, and fallback click - // internally so shownInterfaceWindow is always captured. - await itemManager.temporarilyShow( - item: item, - clickingWith: .left, - on: displayID - ) - } + await itemManager.activate(item: item, on: displayID) } } } diff --git a/Thaw/Resources/Localizable.xcstrings b/Thaw/Resources/Localizable.xcstrings index f367e7bc..fa908a70 100644 --- a/Thaw/Resources/Localizable.xcstrings +++ b/Thaw/Resources/Localizable.xcstrings @@ -1,6 +1,12 @@ { "sourceLanguage" : "en", "strings" : { + "No menu bar items available" : { + "comment" : "Text shown in the per-item hotkeys list when there are no menu bar items to assign a hotkey to." + }, + "Open menu bar items" : { + "comment" : "A disclosure group label in the hotkeys settings pane that expands the list of per-item hotkeys." + }, "(no script selected)" : { "comment" : "A placeholder displayed in the \"Path\" column of a hook row when no script is selected.", "localizations" : { diff --git a/Thaw/Settings/Models/Profile.swift b/Thaw/Settings/Models/Profile.swift index 4864dd98..881bb255 100644 --- a/Thaw/Settings/Models/Profile.swift +++ b/Thaw/Settings/Models/Profile.swift @@ -307,6 +307,12 @@ struct MenuBarLayoutSnapshot: Codable { /// Placement preference for the New Items badge (section and anchor). /// Absent in profiles saved before this field was introduced. var newItemsPlacement: MenuBarItemManager.NewItemsPlacement? + + /// Per-item hotkey bindings keyed by uniqueIdentifier (namespace:title). + /// Each value is an encoded KeyCombination, matching the storage shape of + /// the menuBarItemHotkeys default. Absent in profiles saved before this + /// field was introduced. + var itemHotkeys: [String: Data]? } // MARK: - ProfileContent diff --git a/Thaw/Settings/Models/ProfileManager.swift b/Thaw/Settings/Models/ProfileManager.swift index 3150316c..2c906d57 100644 --- a/Thaw/Settings/Models/ProfileManager.swift +++ b/Thaw/Settings/Models/ProfileManager.swift @@ -451,6 +451,14 @@ final class ProfileManager: ObservableObject { forKey: .menuBarItemCustomNames ) + // Apply per-item hotkeys to UserDefaults, then rebuild the live hotkey + // objects so the restored bindings register immediately. + Defaults.set( + profile.menuBarLayout.itemHotkeys ?? [:], + forKey: .menuBarItemHotkeys + ) + appState.menuBarManager.rebuildItemHotkeys() + // Apply the New Items badge placement before starting the layout // task, so late-arriving items land in the profile-defined spot. if let placement = profile.menuBarLayout.newItemsPlacement { @@ -574,6 +582,9 @@ final class ProfileManager: ObservableObject { let customNames = Defaults.dictionary( forKey: .menuBarItemCustomNames ) as? [String: String] ?? [:] + let itemHotkeys = Defaults.dictionary( + forKey: .menuBarItemHotkeys + ) as? [String: Data] ?? [:] // itemOrder must agree with savedSectionOrder; they are two // representations of the same "where does each item belong?" @@ -606,7 +617,8 @@ final class ProfileManager: ObservableObject { customNames: customNames, itemSectionMap: itemSectionMap, itemOrder: itemOrder, - newItemsPlacement: appState.itemManager.newItemsPlacement + newItemsPlacement: appState.itemManager.newItemsPlacement, + itemHotkeys: itemHotkeys ) } diff --git a/Thaw/Settings/SettingsPanes/HotkeysSettingsPane.swift b/Thaw/Settings/SettingsPanes/HotkeysSettingsPane.swift index d56246f3..469711d0 100644 --- a/Thaw/Settings/SettingsPanes/HotkeysSettingsPane.swift +++ b/Thaw/Settings/SettingsPanes/HotkeysSettingsPane.swift @@ -20,6 +20,11 @@ struct HotkeysSettingsPane: View { } IceSection("Menu Bar Items") { hotkeyRecorder(forAction: .searchMenuBarItems) + MenuBarItemHotkeyList( + menuBarManager: appState.menuBarManager, + itemManager: appState.itemManager, + imageCache: appState.imageCache + ) } if !appState.profileManager.profiles.isEmpty { IceSection("Profiles") { @@ -52,6 +57,8 @@ struct HotkeysSettingsPane: View { Text("Toggle application menus") case .profileApply: EmptyView() + case .openMenuBarItem: + EmptyView() } } } @@ -77,3 +84,173 @@ struct HotkeysSettingsPane: View { } } } + +// MARK: - MenuBarItemHotkeyList + +/// A collapsible list of per-item hotkey recorders. Each row pairs a menu bar +/// item (icon and name) with a recorder that opens the item's menu when the +/// hotkey fires. Items with a saved binding whose owning app is not currently +/// running are still listed, marked unavailable, so the binding can be cleared. +private struct MenuBarItemHotkeyList: View { + @ObservedObject var menuBarManager: MenuBarManager + @ObservedObject var itemManager: MenuBarItemManager + @ObservedObject var imageCache: MenuBarItemImageCache + + @State private var isExpanded = false + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + let rows = makeRows() + if rows.isEmpty { + Text("No menu bar items available") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 6) + } else { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 10) { + ForEach(rows, id: \.id) { row in + GridRow { + iconView(for: row) + .gridColumnAlignment(.center) + Text(row.name) + .lineLimit(1) + // Claim the name's full width so it is not + // truncated by the flexible spacer column. + .fixedSize(horizontal: true, vertical: false) + .foregroundStyle(row.item != nil ? .primary : .secondary) + Text(row.bundle) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + // Claim the bundle's full width so the flexible + // spacer column below does not compress it. + .fixedSize(horizontal: true, vertical: false) + // Flexible spacer column: absorbs the slack so the + // name and bundle stay compact on the left while the + // recorder is pushed to the trailing edge. + Color.clear + .frame(maxWidth: .infinity, maxHeight: 1) + HotkeyRecorder(hotkey: row.hotkey) { + EmptyView() + } + } + } + } + .padding(.top, 6) + } + } label: { + Text("Open menu bar items") + } + // Tell the image cache whether this list is visible so it only runs the + // live capture loop while the disclosure is expanded. + .onChange(of: isExpanded, initial: true) { _, expanded in + imageCache.setItemHotkeyListExpanded(expanded) + } + .onDisappear { + imageCache.setItemHotkeyListExpanded(false) + } + .task(id: isExpanded) { + // Item images for the hidden and always-hidden sections are not + // captured until something requests them. Prewarm all sections when + // the list is expanded so off-screen items show their real icon. + guard isExpanded else { return } + await imageCache.updateCacheWithoutChecks(sections: MenuBarSection.Name.allCases) + } + } + + @ViewBuilder + private func iconView(for row: Row) -> some View { + if let image = row.item.flatMap({ imageCache.images[$0.tag]?.nsImage }) { + // Render at the captured size (the nsImage already carries the + // item's scaled point size), matching the Layout pane rather than + // forcing a square that distorts wide items like Clock or Outlook. + Image(nsImage: image) + } else { + // No captured image for an absent item; show a neutral placeholder + // sized to roughly the menu bar item height. + Image(systemName: "questionmark.square.dashed") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 18) + .foregroundStyle(.secondary) + } + } + + private struct Row { + let id: String + let name: String + let bundle: String + let item: MenuBarItem? + let hotkey: Hotkey + } + + private func makeRows() -> [Row] { + var rows: [Row] = [] + var seen = Set() + + // Present items grouped by section (visible, hidden, always-hidden), + // reversed within each section so the rightmost menu bar item (e.g. the + // clock) appears first. + for section in MenuBarSection.Name.allCases { + for item in itemManager.itemCache.managedItems(for: section).reversed() + where !item.isControlItem && item.sourcePID != nil + { + let id = item.uniqueIdentifier + guard let hotkey = menuBarManager.itemHotkeys[id], seen.insert(id).inserted else { + continue + } + rows.append(Row( + id: id, + name: item.displayName, + bundle: item.tag.namespace.description, + item: item, + hotkey: hotkey + )) + } + } + + // Configured-but-absent items (owning app not currently running). + // itemHotkeys is an unordered dictionary, so sort by name (then id) for + // a stable row order across renders. + let absent = menuBarManager.itemHotkeys + .filter { id, hotkey in hotkey.keyCombination != nil && !seen.contains(id) } + .map { (id: $0.key, hotkey: $0.value, name: lastKnownName(for: $0.key)) } + .sorted { lhs, rhs in + if lhs.name != rhs.name { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return lhs.id < rhs.id + } + for entry in absent { + rows.append(Row( + id: entry.id, + name: entry.name, + bundle: bundle(forIdentifier: entry.id), + item: nil, + hotkey: entry.hotkey + )) + } + + return rows + } + + /// Best-effort display name for an absent item: the saved custom name if + /// present, otherwise the title component of its identifier. + private func lastKnownName(for identifier: String) -> String { + let customNames = Defaults.dictionary(forKey: .menuBarItemCustomNames) as? [String: String] ?? [:] + if let custom = customNames[identifier], !custom.isEmpty { + return custom + } + // identifier is "namespace:title[:index]"; surface the title component. + let parts = identifier.split(separator: ":") + if parts.count >= 2 { + return String(parts[1]) + } + return identifier + } + + /// The bundle (namespace) component of an item identifier. + private func bundle(forIdentifier identifier: String) -> String { + String(identifier.split(separator: ":").first ?? "") + } +} diff --git a/Thaw/Utilities/Defaults.swift b/Thaw/Utilities/Defaults.swift index 3e5dabcb..94afb981 100644 --- a/Thaw/Utilities/Defaults.swift +++ b/Thaw/Utilities/Defaults.swift @@ -231,6 +231,7 @@ extension Defaults { case hotkeys = "Hotkeys" case profileHotkeys = "ProfileHotkeys" + case menuBarItemHotkeys = "MenuBarItemHotkeys" // MARK: Advanced Settings diff --git a/ThawTests/HotkeyActionTests.swift b/ThawTests/HotkeyActionTests.swift index 1528342b..79b4c3e3 100644 --- a/ThawTests/HotkeyActionTests.swift +++ b/ThawTests/HotkeyActionTests.swift @@ -19,6 +19,7 @@ final class HotkeyActionTests: XCTestCase { XCTAssertEqual(HotkeyAction.enableIceBar.rawValue, "EnableIceBar") XCTAssertEqual(HotkeyAction.toggleApplicationMenus.rawValue, "ToggleApplicationMenus") XCTAssertEqual(HotkeyAction.profileApply.rawValue, "ProfileApply") + XCTAssertEqual(HotkeyAction.openMenuBarItem.rawValue, "OpenMenuBarItem") } // MARK: - Init from Raw Value Tests @@ -38,7 +39,7 @@ final class HotkeyActionTests: XCTestCase { // MARK: - CaseIterable Tests func testAllCasesCount() { - XCTAssertEqual(HotkeyAction.allCases.count, 6) + XCTAssertEqual(HotkeyAction.allCases.count, 7) } func testAllCasesContainsExpectedActions() { @@ -49,6 +50,7 @@ final class HotkeyActionTests: XCTestCase { XCTAssertTrue(allCases.contains(.enableIceBar)) XCTAssertTrue(allCases.contains(.toggleApplicationMenus)) XCTAssertTrue(allCases.contains(.profileApply)) + XCTAssertTrue(allCases.contains(.openMenuBarItem)) } // MARK: - Settings Actions Tests @@ -58,6 +60,11 @@ final class HotkeyActionTests: XCTestCase { XCTAssertFalse(settingsActions.contains(.profileApply)) } + func testSettingsActionsExcludesOpenMenuBarItem() { + let settingsActions = HotkeyAction.settingsActions + XCTAssertFalse(settingsActions.contains(.openMenuBarItem)) + } + func testSettingsActionsContainsOtherActions() { let settingsActions = HotkeyAction.settingsActions XCTAssertTrue(settingsActions.contains(.toggleHiddenSection)) @@ -68,8 +75,8 @@ final class HotkeyActionTests: XCTestCase { } func testSettingsActionsCount() { - // All cases minus profileApply - XCTAssertEqual(HotkeyAction.settingsActions.count, HotkeyAction.allCases.count - 1) + // All cases minus the externally-handled profileApply and openMenuBarItem. + XCTAssertEqual(HotkeyAction.settingsActions.count, HotkeyAction.allCases.count - 2) } // MARK: - Codable Tests diff --git a/ThawTests/ProfileTests.swift b/ThawTests/ProfileTests.swift index 695546c5..4e9fc5c0 100644 --- a/ThawTests/ProfileTests.swift +++ b/ThawTests/ProfileTests.swift @@ -160,6 +160,30 @@ final class MenuBarLayoutSnapshotTests: XCTestCase { XCTAssertNil(decoded.itemSectionMap) XCTAssertNil(decoded.itemOrder) XCTAssertNil(decoded.newItemsPlacement) + XCTAssertNil(decoded.itemHotkeys) + } + + func testEncodeDecodeItemHotkeys() throws { + let original = MenuBarLayoutSnapshot( + savedSectionOrder: [:], + pinnedHiddenBundleIDs: [], + pinnedAlwaysHiddenBundleIDs: [], + customNames: [:], + itemHotkeys: [ + "com.apple.controlcenter:WiFi": Data([0x01, 0x02]), + "com.apple.controlcenter:Battery": Data([0x03]), + ] + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(original) + let decoded = try decoder.decode(MenuBarLayoutSnapshot.self, from: data) + + XCTAssertEqual(decoded.itemHotkeys?.count, 2) + XCTAssertEqual(decoded.itemHotkeys?["com.apple.controlcenter:WiFi"], Data([0x01, 0x02])) + XCTAssertEqual(decoded.itemHotkeys?["com.apple.controlcenter:Battery"], Data([0x03])) } func testEmptyCollections() {