diff --git a/Ice/Main/AppState.swift b/Ice/Main/AppState.swift index 850ee0f..12c58b1 100644 --- a/Ice/Main/AppState.swift +++ b/Ice/Main/AppState.swift @@ -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) diff --git a/Ice/MenuBar/MenuBarItemImageCache.swift b/Ice/MenuBar/MenuBarItemImageCache.swift index da3448e..fcd3e6c 100644 --- a/Ice/MenuBar/MenuBarItemImageCache.swift +++ b/Ice/MenuBar/MenuBarItemImageCache.swift @@ -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. @@ -24,30 +25,33 @@ final class MenuBarItemImageCache: ObservableObject { /// Storage for internal observers. private var cancellables = Set() + /// 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() 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() @@ -55,12 +59,7 @@ final class MenuBarItemImageCache: ObservableObject { ) .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) } @@ -68,118 +67,108 @@ final class MenuBarItemImageCache: ObservableObject { 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 @@ -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 @@ -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 } @@ -240,7 +230,7 @@ final class MenuBarItemImageCache: ObservableObject { sectionsNeedingDisplay.append(section) } - await updateCacheWithoutChecks(sections: sectionsNeedingDisplay) + updateCacheWithoutChecks(sections: sectionsNeedingDisplay) } } diff --git a/Ice/MenuBar/Search/MenuBarSearchPanel.swift b/Ice/MenuBar/Search/MenuBarSearchPanel.swift index dda8523..3a291f6 100644 --- a/Ice/MenuBar/Search/MenuBarSearchPanel.swift +++ b/Ice/MenuBar/Search/MenuBarSearchPanel.swift @@ -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() diff --git a/Ice/UI/IceBar/IceBar.swift b/Ice/UI/IceBar/IceBar.swift index 833b253..c85c06d 100644 --- a/Ice/UI/IceBar/IceBar.swift +++ b/Ice/UI/IceBar/IceBar.swift @@ -44,8 +44,7 @@ final class IceBarPanel: NSPanel { private func configureCancellables() { var c = Set() - // 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) @@ -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 @@ -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() @@ -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 @@ -157,12 +156,11 @@ 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() @@ -170,12 +168,10 @@ final class IceBarPanel: NSPanel { 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() @@ -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 { diff --git a/Ice/UI/LayoutBar/LayoutBar.swift b/Ice/UI/LayoutBar/LayoutBar.swift index 9c20d2a..419e30d 100644 --- a/Ice/UI/LayoutBar/LayoutBar.swift +++ b/Ice/UI/LayoutBar/LayoutBar.swift @@ -49,11 +49,11 @@ struct LayoutBar: View { @ViewBuilder private var conditionalBody: some View { - if imageCache.cacheFailed(for: section.name) { + if imageCache.hasImages(for: section.name) { + Representable(appState: appState, section: section, spacing: spacing) + } else { Text("Unable to display menu bar items") .foregroundStyle(menuBarManager.averageColorInfo?.color.brightness ?? 0 > 0.67 ? .black : .white) - } else { - Representable(appState: appState, section: section, spacing: spacing) } }