Skip to content

Commit

Permalink
Update menu bar item image cache
Browse files Browse the repository at this point in the history
  • Loading branch information
jordanbaird committed Sep 20, 2024
1 parent 772bcf6 commit cda5f03
Show file tree
Hide file tree
Showing 5 changed files with 109 additions and 122 deletions.
11 changes: 6 additions & 5 deletions Ice/Main/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,14 @@ final class AppState: ObservableObject {
navigationState.$isSettingsPresented
)
.debounce(for: 0.1, scheduler: DispatchQueue.main)
.sink { shouldUpdate in
guard shouldUpdate else {
.sink { [weak self] shouldUpdate in
guard
let self,
shouldUpdate
else {
return
}
Task {
await self.imageCache.updateCacheWithoutChecks(sections: MenuBarSection.Name.allCases)
}
imageCache.updateCacheWithoutChecks(sections: MenuBarSection.Name.allCases)
}
.store(in: &c)

Expand Down
182 changes: 86 additions & 96 deletions Ice/MenuBar/MenuBarItemImageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Cocoa
import Combine
import OSLog

/// Cache for menu bar item images.
@MainActor
final class MenuBarItemImageCache: ObservableObject {
/// The cached item images.
Expand All @@ -24,162 +25,150 @@ final class MenuBarItemImageCache: ObservableObject {
/// Storage for internal observers.
private var cancellables = Set<AnyCancellable>()

/// Creates a cache with the given app state.
init(appState: AppState) {
self.appState = appState
}

/// Sets up the cache.
func performSetup() {
configureCancellables()
}

/// Configures the internal observers for the cache.
private func configureCancellables() {
var c = Set<AnyCancellable>()

if let appState {
Publishers.Merge3(
// update every 3 seconds at minimum
// Update every 3 seconds at minimum.
Timer.publish(every: 3, on: .main, in: .default).autoconnect().mapToVoid(),

// update when the active space or screen parameters change
// Update when the active space or screen parameters change.
Publishers.Merge(
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.activeSpaceDidChangeNotification),
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
)
.mapToVoid(),

// update when the average menu bar color or cached items change
// Update when the average menu bar color or cached items change.
Publishers.Merge(
appState.menuBarManager.$averageColorInfo.removeDuplicates().mapToVoid(),
appState.itemManager.$itemCache.removeDuplicates().mapToVoid()
)
)
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: false)
.sink { [weak self] in
guard let self else {
return
}
Task {
await self.updateCache()
}
self?.updateCache()
}
.store(in: &c)
}

cancellables = c
}

func cacheFailed(for section: MenuBarSection.Name) -> Bool {
/// Returns a Boolean value that indicates whether the cache contains at least _some_
/// images for the given section.
func hasImages(for section: MenuBarSection.Name) -> Bool {
let items = appState?.itemManager.itemCache.allItems(for: section) ?? []
guard !items.isEmpty else {
return false
}
let keys = Set(images.keys)
for item in items where keys.contains(item.info) {
return false
}
return true
return !Set(items.map { $0.info }).isDisjoint(with: images.keys)
}

func createImages(for section: MenuBarSection.Name, screen: NSScreen) async -> [MenuBarItemInfo: CGImage] {
actor TempCache {
private(set) var images = [MenuBarItemInfo: CGImage]()

func cache(image: CGImage, with info: MenuBarItemInfo) {
images[info] = image
}
}

/// Captures the images of the current menu bar items and returns a dictionary containing
/// the images, keyed by the current menu bar item infos.
func createImages(for section: MenuBarSection.Name, screen: NSScreen) -> [MenuBarItemInfo: CGImage] {
guard let appState else {
return [:]
}

let items = appState.itemManager.itemCache.allItems(for: section)

let tempCache = TempCache()
var images = [MenuBarItemInfo: CGImage]()
let backingScaleFactor = screen.backingScaleFactor
let displayBounds = CGDisplayBounds(screen.displayID)
let option: CGWindowImageOption = [.boundsIgnoreFraming, .bestResolution]
let defaultItemThickness = NSStatusBar.system.thickness * backingScaleFactor

let cacheTask = Task.detached {
var itemInfos = [CGWindowID: MenuBarItemInfo]()
var itemFrames = [CGWindowID: CGRect]()
var windowIDs = [CGWindowID]()
var frame = CGRect.null
var itemInfos = [CGWindowID: MenuBarItemInfo]()
var itemFrames = [CGWindowID: CGRect]()
var windowIDs = [CGWindowID]()
var frame = CGRect.null

for item in items {
let windowID = item.windowID
guard
// Use the most up-to-date window frame.
let itemFrame = Bridging.getWindowFrame(for: windowID),
itemFrame.minY == displayBounds.minY
else {
continue
}
itemInfos[windowID] = item.info
itemFrames[windowID] = itemFrame
windowIDs.append(windowID)
frame = frame.union(itemFrame)
}

for item in items {
let windowID = item.windowID
if
let compositeImage = ScreenCapture.captureWindows(windowIDs, option: option),
CGFloat(compositeImage.width) == frame.width * backingScaleFactor
{
for windowID in windowIDs {
guard
// use the most up-to-date window frame
let itemFrame = Bridging.getWindowFrame(for: windowID),
itemFrame.minY == displayBounds.minY
let itemInfo = itemInfos[windowID],
let itemFrame = itemFrames[windowID]
else {
continue
}
itemInfos[windowID] = item.info
itemFrames[windowID] = itemFrame
windowIDs.append(windowID)
frame = frame.union(itemFrame)

let frame = CGRect(
x: (itemFrame.origin.x - frame.origin.x) * backingScaleFactor,
y: (itemFrame.origin.y - frame.origin.y) * backingScaleFactor,
width: itemFrame.width * backingScaleFactor,
height: itemFrame.height * backingScaleFactor
)

guard let itemImage = compositeImage.cropping(to: frame) else {
continue
}

images[itemInfo] = itemImage
}
} else {
Logger.imageCache.warning("Composite image capture failed. Attempting to capturing items individually.")

if
let compositeImage = ScreenCapture.captureWindows(windowIDs, option: option),
CGFloat(compositeImage.width) == frame.width * backingScaleFactor
{
for windowID in windowIDs {
guard
let itemInfo = itemInfos[windowID],
let itemFrame = itemFrames[windowID]
else {
continue
}

let frame = CGRect(
x: (itemFrame.origin.x - frame.origin.x) * backingScaleFactor,
y: (itemFrame.origin.y - frame.origin.y) * backingScaleFactor,
width: itemFrame.width * backingScaleFactor,
height: itemFrame.height * backingScaleFactor
)

guard let itemImage = compositeImage.cropping(to: frame) else {
continue
}

await tempCache.cache(image: itemImage, with: itemInfo)
for windowID in windowIDs {
guard
let itemInfo = itemInfos[windowID],
let itemFrame = itemFrames[windowID]
else {
continue
}
} else {
for windowID in windowIDs {
guard
let itemInfo = itemInfos[windowID],
let itemFrame = itemFrames[windowID]
else {
continue
}

let frame = CGRect(
x: 0,
y: ((itemFrame.height * backingScaleFactor) / 2) - (defaultItemThickness / 2),
width: itemFrame.width * backingScaleFactor,
height: defaultItemThickness
)

guard
let itemImage = ScreenCapture.captureWindow(windowID, option: option),
let croppedImage = itemImage.cropping(to: frame)
else {
continue
}

await tempCache.cache(image: croppedImage, with: itemInfo)

let frame = CGRect(
x: 0,
y: ((itemFrame.height * backingScaleFactor) / 2) - (defaultItemThickness / 2),
width: itemFrame.width * backingScaleFactor,
height: defaultItemThickness
)

guard
let itemImage = ScreenCapture.captureWindow(windowID, option: option),
let croppedImage = itemImage.cropping(to: frame)
else {
continue
}

images[itemInfo] = croppedImage
}
}

await cacheTask.value
return await tempCache.images
return images
}

func updateCacheWithoutChecks(sections: [MenuBarSection.Name]) async {
/// Updates the cache with the current menu bar item images, without checking whether
/// caching is necessary.
func updateCacheWithoutChecks(sections: [MenuBarSection.Name]) {
guard
let appState,
let screen = NSScreen.main
Expand All @@ -191,7 +180,7 @@ final class MenuBarItemImageCache: ObservableObject {
guard !appState.itemManager.itemCache.allItems(for: section).isEmpty else {
continue
}
let sectionImages = await createImages(for: section, screen: screen)
let sectionImages = createImages(for: section, screen: screen)
guard !sectionImages.isEmpty else {
Logger.imageCache.warning("Update image cache failed for \(section.logString)")
continue
Expand All @@ -203,7 +192,8 @@ final class MenuBarItemImageCache: ObservableObject {
self.menuBarHeight = screen.getMenuBarHeight()
}

func updateCache() async {
/// Updates the cache with the current menu bar item images, if necessary.
func updateCache() {
guard let appState else {
return
}
Expand Down Expand Up @@ -240,7 +230,7 @@ final class MenuBarItemImageCache: ObservableObject {
sectionsNeedingDisplay.append(section)
}

await updateCacheWithoutChecks(sections: sectionsNeedingDisplay)
updateCacheWithoutChecks(sections: sectionsNeedingDisplay)
}
}

Expand Down
2 changes: 1 addition & 1 deletion Ice/MenuBar/Search/MenuBarSearchPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ final class MenuBarSearchPanel: NSPanel {
// Important that we set the navigation before updating the cache
appState.navigationState.isSearchPresented = true

await appState.imageCache.updateCache()
appState.imageCache.updateCache()

contentView = MenuBarSearchHostingView(appState: appState, closePanel: { [weak self] in
self?.close()
Expand Down
30 changes: 13 additions & 17 deletions Ice/UI/IceBar/IceBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ final class IceBarPanel: NSPanel {
private func configureCancellables() {
var c = Set<AnyCancellable>()

// close the panel when the active space changes, or when the
// screen parameters change
// Close the panel when the active space changes, or when the screen parameters change.
Publishers.Merge(
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.activeSpaceDidChangeNotification),
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
Expand All @@ -65,12 +64,12 @@ final class IceBarPanel: NSPanel {
.sink { [weak self, weak window] _ in
guard
let self,
// only continue if the menu bar is automatically hidden, as Ice
// can't currently display its menu bar items
// Only continue if the menu bar is automatically hidden, as Ice
// can't currently display its menu bar items.
appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults,
let info = window.flatMap({ WindowInfo(windowID: CGWindowID($0.windowNumber)) }),
// window being offscreen means the menu bar is currently hidden;
// close the bar, as things will start to look weird if we don't
// Window being offscreen means the menu bar is currently hidden.
// Close the bar, as things will start to look weird if we don't.
!info.isOnScreen
else {
return
Expand All @@ -80,7 +79,7 @@ final class IceBarPanel: NSPanel {
.store(in: &c)
}

// update the panel's origin whenever its size changes
// Update the panel's origin whenever its size changes.
publisher(for: \.frame)
.map(\.size)
.removeDuplicates()
Expand Down Expand Up @@ -139,7 +138,7 @@ final class IceBarPanel: NSPanel {
let section = appState.menuBarManager.section(withName: .visible),
let windowID = section.controlItem.windowID,
// Bridging.getWindowFrame is more reliable than ControlItem.windowFrame,
// i.e. if the control item is offscreen
// i.e. if the control item is offscreen.
let itemFrame = Bridging.getWindowFrame(for: windowID)
else {
return originForRightOfScreen
Expand All @@ -157,25 +156,22 @@ final class IceBarPanel: NSPanel {
return
}

// important that we set the navigation state and current section
// before updating the cache
// Important that we set the navigation state and current section before updating the cache.
appState.navigationState.isIceBarPresented = true
currentSection = section

await appState.imageCache.updateCache()
appState.imageCache.updateCache()

contentView = IceBarHostingView(appState: appState, colorManager: colorManager, section: section) { [weak self] in
self?.close()
}

updateOrigin(for: screen)

// the color manager must be updated after updating the panel's
// origin, but before it is shown...
// Color manager must be updated after updating the panel's origin, but before it is shown.
//
// the color manager handles frame changes automatically, but does
// so on the main queue, so we need to update manually once before
// showing the panel to prevent the color from flashing
// Color manager handles frame changes automatically, but does so on the main queue, so we
// need to update manually once before showing the panel to prevent the color from flashing.
colorManager.updateAllProperties(with: frame, screen: screen)

orderFrontRegardless()
Expand Down Expand Up @@ -310,7 +306,7 @@ private struct IceBarContentView: View {
if menuBarManager.isMenuBarHiddenBySystemUserDefaults {
Text("Ice cannot display menu bar items for automatically hidden menu bars")
.padding(.horizontal, 5)
} else if imageCache.cacheFailed(for: section) {
} else if !imageCache.hasImages(for: section) {
Text("Unable to display menu bar items")
.padding(.horizontal, 5)
} else {
Expand Down
Loading

0 comments on commit cda5f03

Please sign in to comment.