Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Shared/Utilities/AXHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
5 changes: 5 additions & 0 deletions Thaw/Hotkeys/Hotkey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
14 changes: 11 additions & 3 deletions Thaw/Hotkeys/HotkeyAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
60 changes: 53 additions & 7 deletions Thaw/MenuBar/MenuBarItems/MenuBarItemImageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand All @@ -390,15 +415,17 @@ final class MenuBarItemImageCache: ObservableObject, @unchecked Sendable {
isSearchPresented: false,
isAppFrontmost: false,
isSettingsPresented: false,
settingsNavigationIdentifier: nil
settingsNavigationIdentifier: nil,
isItemHotkeyListExpanded: false
)
}
return NavigationStateSnapshot(
isIceBarPresented: appState.navigationState.isIceBarPresented,
isSearchPresented: appState.navigationState.isSearchPresented,
isAppFrontmost: appState.navigationState.isAppFrontmost,
isSettingsPresented: appState.navigationState.isSettingsPresented,
settingsNavigationIdentifier: appState.navigationState.settingsNavigationIdentifier
settingsNavigationIdentifier: appState.navigationState.settingsNavigationIdentifier,
isItemHotkeyListExpanded: isItemHotkeyListExpanded
)
}

Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading