From f34a6cea8dd223e9d1e16939aeaed54e4d541f6c Mon Sep 17 00:00:00 2001 From: Adam Conway Date: Sat, 16 May 2026 23:13:25 +0100 Subject: [PATCH 1/3] fix: guard menu-bar widget paths against unhosted state Several widget redraw and resize paths could fire while a widget was not yet attached to its hosting NSStatusItem button, or while the button itself had no associated NSWindow (windowNumber == -1). When that happened, calls into setFrameSize/display/NSStatusItem.length would still reach AppKit, which forwarded them to WindowServer and caused log spamming to occur. The same paths were also unconditionally re-adding the widget view to its host on every status-item setup, causing needless view-hierarchy churn on toggle. This change introduces a small hasUsableHost helper on WidgetWrapper that requires both a non-nil superview and a window with a valid windowNumber, and uses it to early-return from: - WidgetWrapper.setWidth (before setFrameSize/widthHandler) - WidgetWrapper.redraw (before display) For the per-widget status item, SWidget.widthHandler and MenuBar.recalculateWidth now require the menu-bar button to have a window before assigning NSStatusItem.length, which is what actually triggers the WindowServer round-trip and log spam. SWidget.setMenuBarItem and MenuBarView.addWidget are also made idempotent: they only call addSubview when the view isn't already parented to the intended host, removing repeated reparent work on toggle and one-view changes. Two unrelated fixes are included in the same change because they surfaced while reproducing the menu-bar issue on macOS Tahoe: - Modules/Net/preview.swift: the network preview chart was being initialised without an explicit frame, leaving it sized 0x0 until the first layout pass. Provide an explicit (0, 0, 0, 140) frame so the preview renders correctly on first display. - Modules/Net/readers.swift: guard the CWPHYMode.mode11be case in the CustomStringConvertible extension behind `#if compiler(<6.2)`. The symbol is not exposed by the CoreWLAN headers shipped with the Tahoe SDK (Xcode 26/Swift 6.2), so unconditionally referencing it breaks the build on that toolchain. Older toolchains (Sonoma, Sequoia) continue to render Wi-Fi 7 networks as "802.11be". --- Kit/module/widget.swift | 46 +++++++++++++++++++++++++++------------ Modules/Net/preview.swift | 2 +- Modules/Net/readers.swift | 2 ++ 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/Kit/module/widget.swift b/Kit/module/widget.swift index 45b622ddeb8..6ccdb6e03b1 100644 --- a/Kit/module/widget.swift +++ b/Kit/module/widget.swift @@ -162,6 +162,12 @@ open class WidgetWrapper: NSView, widget_p { public var onClick: (() -> Void)? = nil public var shadowSize: CGSize internal var queue: DispatchQueue + + private var hasUsableHost: Bool { + guard self.superview != nil else { return false } + guard let window = self.window else { return false } + return window.windowNumber != -1 + } public init(_ type: widget_t, title: String, frame: NSRect) { self.type = type @@ -186,6 +192,7 @@ open class WidgetWrapper: NSView, widget_p { self.shadowSize.width = newWidth DispatchQueue.main.async { + guard self.hasUsableHost else { return } self.setFrameSize(NSSize(width: newWidth, height: self.frame.size.height)) self.widthHandler?() } @@ -215,7 +222,8 @@ open class WidgetWrapper: NSView, widget_p { public func redraw() { DispatchQueue.main.async { [weak self] in - self?.display() + guard let self, self.hasUsableHost else { return } + self.display() } } @@ -279,10 +287,12 @@ public class SWidget { self.originX = item.frame.origin.x self.item.widthHandler = { [weak self] in - self?.sizeCallback?() - if let s = self, let item = s.menuBarItem, let width: CGFloat = self?.item.frame.width, item.length != width { - item.length = width - } + guard let s = self else { return } + s.sizeCallback?() + guard let item = s.menuBarItem, + item.button?.window != nil, + item.length != s.item.frame.width else { return } + item.length = s.item.frame.width } self.item.identifier = NSUserInterfaceItemIdentifier(self.type.rawValue) } @@ -332,17 +342,21 @@ public class SWidget { if self.item.frame.origin.x != self.originX { self.item.setFrameOrigin(NSPoint(x: self.originX, y: self.item.frame.origin.y)) } - self.menuBarItem?.button?.addSubview(self.item) - self.menuBarItem?.button?.image = NSImage() - self.menuBarItem?.button?.toolTip = "\(localizedString(self.module)): \(self.type.name())" + guard let button = self.menuBarItem?.button else { return } + if self.item.superview !== button { + self.item.removeFromSuperview() + button.addSubview(self.item) + } + button.image = NSImage() + button.toolTip = "\(localizedString(self.module)): \(self.type.name())" if let item = self.menuBarItem, !item.isVisible { self.menuBarItem?.isVisible = true } - self.menuBarItem?.button?.target = self - self.menuBarItem?.button?.action = #selector(self.togglePopup) - self.menuBarItem?.button?.sendAction(on: [.leftMouseDown, .rightMouseDown]) + button.target = self + button.action = #selector(self.togglePopup) + button.sendAction(on: [.leftMouseDown, .rightMouseDown]) }) } else if let item = self.menuBarItem { NSStatusBar.system.removeStatusItem(item) @@ -492,11 +506,12 @@ public class MenuBar { private func recalculateWidth() { guard self.oneView, self.active else { return } - + guard let item = self.menuBarItem, item.button?.window != nil else { return } + let w = self.activeWidgets.map({ $0.item.frame.width }).reduce(0, +) + (CGFloat(self.activeWidgets.count - 1) * Constants.Widget.spacing) + Constants.Widget.spacing * 2 - self.menuBarItem?.length = w + item.length = w self.view.setFrameOrigin(NSPoint(x: 0, y: 0)) self.view.setFrameSize(NSSize(width: w, height: Constants.Widget.height)) @@ -558,7 +573,10 @@ public class MenuBarView: NSView { } public func addWidget(_ view: NSView) { - self.addSubview(view) + if view.superview !== self { + view.removeFromSuperview() + self.addSubview(view) + } } public func removeWidget(type: widget_t) { diff --git a/Modules/Net/preview.swift b/Modules/Net/preview.swift index b584f2f1e98..5fa3a25f898 100644 --- a/Modules/Net/preview.swift +++ b/Modules/Net/preview.swift @@ -139,7 +139,7 @@ internal class Preview: PreviewWrapper { view.spacing = Constants.Settings.margin*2 view.heightAnchor.constraint(equalToConstant: 140).isActive = true - let chart = NetworkChartView(num: 600) + let chart = NetworkChartView(frame: NSRect(x: 0, y: 0, width: 0, height: 140), num: 600) self.chart = chart chart.setColors(in: self.downloadColor, out: self.uploadColor) chart.setLegend(x: true, y: false) diff --git a/Modules/Net/readers.swift b/Modules/Net/readers.swift index cf058d19bf3..65e8ed53e83 100644 --- a/Modules/Net/readers.swift +++ b/Modules/Net/readers.swift @@ -30,7 +30,9 @@ extension CWPHYMode: @retroactive CustomStringConvertible { case .mode11g: return "802.11g" case .mode11n: return "802.11n" case .mode11ax: return "802.11ax" + #if compiler(<6.2) case .mode11be: return "802.11be" + #endif case .modeNone: return "none" @unknown default: return "unknown" } From 56d83968080afa762dca36dcf52a7d1b48a37e36 Mon Sep 17 00:00:00 2001 From: Adam Conway Date: Sun, 17 May 2026 11:57:15 +0100 Subject: [PATCH 2/3] fix: render menu-bar widgets to layer.contents to bypass setNeedsDisplayInRect spam The hasUsableHost guards added in the previous commit got us about halfway. On macOS 26 (Tahoe) I discovered later on that WindowServer was still logging _CGXPackagesSetWindowConstraints: Invalid window at roughly 3.9 hits/sec whenever widgets were visible, and lldb pointed at why: every spam line originated from a widget calling needsDisplay = true/display() inside its status-item button, not from anything the hasUsableHost guards covered. The actual chain is that NSView.setNeedsDisplayInRect: now posts a CoreFoundation notification on every view invalidation. NSStatusBarWindow observes that notification on Tahoe and responds by calling NSStatusItem._windowNeedsReplicantUpdate:, which pushes a window-geometry constraint to SkyLight via _CGXPackagesSetWindowConstraints. If the status item's backing window is in any transitional state at that moment (which is much more frequent on Tahoe due to menu bar overflow, the notch, and Liquid Glass animation), SkyLight logs Invalid window and drops the request. Every widget redraw round-trips through WindowServer regardless of whether NSStatusItem.length is ever touched, and swapping display() for needsDisplay = true does nothing because both end up at setNeedsDisplayInRect: internally. This change replaces the AppKit invalidation path with a layer-contents pipeline. Each WidgetWrapper now sets wantsLayer = true at init and gains a renderToLayer() method that renders the widget's existing draw(_:) output off-screen into a CGContext-backed CGImage and assigns it directly to layer.contents inside a CATransaction with implicit animations disabled. The notification chain never fires because setNeedsDisplayInRect: is never called. Call sites that previously triggered redraws are swapped accordingly: - Every needsDisplay = true and display() call in Mini, Memory, Speed, Battery, Dot, LineChart, BarChart, NetworkChart, Stack, and Text widgets now calls renderToLayer() instead. - Charts.displayIfVisible and the CombinedView paths swap the same way. - WidgetWrapper.redraw() is now main-thread-aware and dispatches renderToLayer(). - The widget re-renders on viewDidMoveToWindow and viewDidChangeEffectiveAppearance so its contents are correct after host changes or appearance shifts. The hasUsableHost guards and the previous commit's idempotent addSubview behaviour are retained. They're independent of this change and still useful for skipping work when the status item isn't hosted yet. A new attachToMenuBar(retries:) helper on SWidget and configureMenuBarButton(retries:) on MenuBar replace the inline status-bar setup so the bind-to-button work waits for a valid backing window instead of firing immediately on creation. Measured on macOS 26.5 over a 10h run with all modules active: pre-fix WindowServer was logging the spam at ~3.9 hits/sec sustained whenever Stats was visible. Post-fix: 0.025 hits/sec during active use, 0.006 hits/sec idle, with two small launch-time bursts of ~5 hits each before the renderToLayer pipeline takes over. That's a ~150x reduction during active use and ~650x idle. Stats itself stays flat at 0% CPU and ~64 MB RSS over the run. --- Kit/Widgets/BarChart.swift | 2 +- Kit/Widgets/Battery.swift | 29 +++--- Kit/Widgets/Dot.swift | 2 +- Kit/Widgets/LineChart.swift | 26 ++--- Kit/Widgets/Memory.swift | 14 +-- Kit/Widgets/Mini.swift | 22 ++--- Kit/Widgets/NetworkChart.swift | 20 ++-- Kit/Widgets/Speed.swift | 24 ++--- Kit/Widgets/Stack.swift | 10 +- Kit/Widgets/Text.swift | 2 +- Kit/module/widget.swift | 168 +++++++++++++++++++++++++-------- Kit/plugins/Charts.swift | 94 ++++++++++++++---- Stats/Views/CombinedView.swift | 35 +++++-- 13 files changed, 309 insertions(+), 139 deletions(-) diff --git a/Kit/Widgets/BarChart.swift b/Kit/Widgets/BarChart.swift index 49f1c5c79ff..1462a1bddeb 100644 --- a/Kit/Widgets/BarChart.swift +++ b/Kit/Widgets/BarChart.swift @@ -83,7 +83,7 @@ public class BarChart: WidgetWrapper { } self.setFrameSize(NSSize(width: 36, height: self.frame.size.height)) self.invalidateIntrinsicContentSize() - self.display() + self.renderToLayer() } let style = NSMutableParagraphStyle() diff --git a/Kit/Widgets/Battery.swift b/Kit/Widgets/Battery.swift index 4d9926b50ea..44ed9467f66 100644 --- a/Kit/Widgets/Battery.swift +++ b/Kit/Widgets/Battery.swift @@ -407,18 +407,17 @@ public class BatteryWidget: WidgetWrapper { } if updated { - self.needsDisplay = true DispatchQueue.main.async(execute: { - self.display() + self.renderToLayer() }) } } - + // MARK: - Settings - + public override func settings() -> NSView { let view = SettingsContainerView() - + var additionalOptions = BatteryAdditionals if self.title == "Bluetooth" { additionalOptions = additionalOptions.filter({ $0.key == "none" || $0.key == "percentage" }) @@ -455,31 +454,31 @@ public class BatteryWidget: WidgetWrapper { guard let key = sender.representedObject as? String else { return } self.additional = key Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_additional", value: key) - self.display() + self.renderToLayer() } @objc private func toggleHideAdditionalWhenFull(_ sender: NSControl) { self.hideAdditionalWhenFull = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_hideAdditionalWhenFull", value: self.hideAdditionalWhenFull) - self.display() + self.renderToLayer() } @objc private func toggleColor(_ sender: NSControl) { self.colorState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_color", value: self.colorState) - self.display() + self.renderToLayer() } @objc private func toggleXLSize(_ sender: NSControl) { self.xlSizeState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_xlSize", value: self.xlSizeState) - self.display() + self.renderToLayer() } @objc private func toggleChargerIconInside(_ sender: NSControl) { self.chargerIconInside = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_chargerInside", value: self.chargerIconInside) - self.display() + self.renderToLayer() } } @@ -624,15 +623,15 @@ public class BatteryDetailsWidget: WidgetWrapper { } if updated { - self.needsDisplay = true DispatchQueue.main.async(execute: { - self.display() + self.renderToLayer() }) } } - + // MARK: - Settings - + + public override func settings() -> NSView { let view = SettingsContainerView() @@ -651,6 +650,6 @@ public class BatteryDetailsWidget: WidgetWrapper { guard let key = sender.representedObject as? String else { return } self.mode = key Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_mode", value: key) - self.display() + self.renderToLayer() } } diff --git a/Kit/Widgets/Dot.swift b/Kit/Widgets/Dot.swift index 917a906ed73..376d3fcc12a 100644 --- a/Kit/Widgets/Dot.swift +++ b/Kit/Widgets/Dot.swift @@ -54,7 +54,7 @@ public class DotWidget: WidgetWrapper { guard self.value != value else { return } self.value = value DispatchQueue.main.async(execute: { - self.display() + self.renderToLayer() }) } } diff --git a/Kit/Widgets/LineChart.swift b/Kit/Widgets/LineChart.swift index d9dd5b004d1..41e0f75f914 100644 --- a/Kit/Widgets/LineChart.swift +++ b/Kit/Widgets/LineChart.swift @@ -233,7 +233,9 @@ public class LineChart: WidgetWrapper { height: box.bounds.height - offset ) self.chart.setColor(color) - self.chart.setFrameSize(chartSize) + if self.chart.frame.size != chartSize { + self.chart.setFrameSize(chartSize) + } self.chart.draw(NSRect(origin: .zero, size: chartSize)) context.restoreGState() @@ -251,15 +253,15 @@ public class LineChart: WidgetWrapper { DispatchQueue.main.async(execute: { self._value = newValue self.chart.addValue(newValue) - self.display() + self.renderToLayer() }) } - + public func setPressure(_ newPressureLevel: RAMPressure) { DispatchQueue.main.async(execute: { guard self._pressureLevel != newPressureLevel else { return } self._pressureLevel = newPressureLevel - self.display() + self.renderToLayer() }) } @@ -317,7 +319,7 @@ public class LineChart: WidgetWrapper { @objc private func toggleLabel(_ sender: NSControl) { self.labelState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_label", value: self.labelState) - self.display() + self.renderToLayer() } @objc private func toggleBox(_ sender: NSControl) { @@ -330,7 +332,7 @@ public class LineChart: WidgetWrapper { Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_frame", value: self.frameState) } - self.display() + self.renderToLayer() } @objc private func toggleFrame(_ sender: NSControl) { @@ -343,13 +345,13 @@ public class LineChart: WidgetWrapper { Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_box", value: self.boxState) } - self.display() + self.renderToLayer() } @objc private func toggleValue(_ sender: NSControl) { self.valueState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_value", value: self.valueState) - self.display() + self.renderToLayer() } @objc private func toggleColor(_ sender: NSMenuItem) { @@ -358,13 +360,13 @@ public class LineChart: WidgetWrapper { self.colorState = newColor } Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_color", value: key) - self.display() + self.renderToLayer() } @objc private func toggleValueColor(_ sender: NSControl) { self.valueColorState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_valueColor", value: self.valueColorState) - self.display() + self.renderToLayer() } @objc private func toggleHistoryCount(_ sender: NSMenuItem) { @@ -372,7 +374,7 @@ public class LineChart: WidgetWrapper { self.historyCount = value Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_historyCount", value: value) self.chart.reinit(value) - self.display() + self.renderToLayer() } @objc private func toggleScale(_ sender: NSMenuItem) { @@ -381,6 +383,6 @@ public class LineChart: WidgetWrapper { self.scaleState = value self.chart.setScale(value) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_scale", value: key) - self.display() + self.renderToLayer() } } diff --git a/Kit/Widgets/Memory.swift b/Kit/Widgets/Memory.swift index 5a04f949b45..0db3cad1366 100644 --- a/Kit/Widgets/Memory.swift +++ b/Kit/Widgets/Memory.swift @@ -131,17 +131,17 @@ public class MemoryWidget: WidgetWrapper { public func setValue(_ value: (String, String), usedPercentage: Double) { self.value = value self.percentage = usedPercentage - + DispatchQueue.main.async(execute: { - self.display() + self.renderToLayer() }) } - + public func setPressure(_ newPressureLevel: RAMPressure) { guard self.pressureLevel != newPressureLevel else { return } self.pressureLevel = newPressureLevel DispatchQueue.main.async(execute: { - self.display() + self.renderToLayer() }) } @@ -170,13 +170,13 @@ public class MemoryWidget: WidgetWrapper { @objc private func toggleOrder(_ sender: NSControl) { self.orderReversedState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_orderReversed", value: self.orderReversedState) - self.display() + self.renderToLayer() } @objc private func toggleSymbols(_ sender: NSControl) { self.symbolsState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_symbols", value: self.symbolsState) - self.display() + self.renderToLayer() } @objc private func toggleColor(_ sender: NSMenuItem) { @@ -185,6 +185,6 @@ public class MemoryWidget: WidgetWrapper { self.colorState = newColor } Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_color", value: key) - self.display() + self.renderToLayer() } } diff --git a/Kit/Widgets/Mini.swift b/Kit/Widgets/Mini.swift index 4e384ab1635..ddd42c2bee0 100644 --- a/Kit/Widgets/Mini.swift +++ b/Kit/Widgets/Mini.swift @@ -151,7 +151,7 @@ public class Mini: WidgetWrapper { guard self._value != newValue else { return } self._value = newValue DispatchQueue.main.async(execute: { - self.display() + self.renderToLayer() }) } @@ -159,10 +159,10 @@ public class Mini: WidgetWrapper { guard self._pressureLevel != newPressureLevel else { return } self._pressureLevel = newPressureLevel DispatchQueue.main.async(execute: { - self.needsDisplay = true + self.renderToLayer() }) } - + public func setTitle(_ newTitle: String?) { var title = self.defaultLabel if let new = newTitle { @@ -171,23 +171,23 @@ public class Mini: WidgetWrapper { guard self._label != title else { return } self._label = title DispatchQueue.main.async(execute: { - self.needsDisplay = true + self.renderToLayer() }) } - + public func setColorZones(_ newColorZones: colorZones) { guard self._colorZones != newColorZones else { return } self._colorZones = newColorZones DispatchQueue.main.async(execute: { - self.display() + self.renderToLayer() }) } - + public func setSuffix(_ newSuffix: String) { guard self._suffix != newSuffix else { return } self._suffix = newSuffix DispatchQueue.main.async(execute: { - self.display() + self.renderToLayer() }) } @@ -222,13 +222,13 @@ public class Mini: WidgetWrapper { self.colorState = newColor } Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_color", value: key) - self.display() + self.renderToLayer() } @objc private func toggleLabel(_ sender: NSControl) { self.labelState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_label", value: self.labelState) - self.display() + self.renderToLayer() } @objc private func toggleAlignment(_ sender: NSMenuItem) { @@ -237,6 +237,6 @@ public class Mini: WidgetWrapper { self.alignmentState = newAlignment.key } Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_alignment", value: key) - self.display() + self.renderToLayer() } } diff --git a/Kit/Widgets/NetworkChart.swift b/Kit/Widgets/NetworkChart.swift index 8f708364439..fde1e678c46 100644 --- a/Kit/Widgets/NetworkChart.swift +++ b/Kit/Widgets/NetworkChart.swift @@ -266,9 +266,9 @@ public class NetworkChart: WidgetWrapper { DispatchQueue.main.async(execute: { self.points.remove(at: 0) self.points.append((upload, download)) - + if self.window?.isVisible ?? false { - self.display() + self.renderToLayer() } }) } @@ -328,7 +328,7 @@ public class NetworkChart: WidgetWrapper { @objc private func toggleLabel(_ sender: NSControl) { self.labelState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_label", value: self.labelState) - self.display() + self.renderToLayer() } @objc private func toggleBox(_ sender: NSControl) { @@ -341,7 +341,7 @@ public class NetworkChart: WidgetWrapper { Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_frame", value: self.frameState) } - self.display() + self.renderToLayer() } @objc private func toggleFrame(_ sender: NSControl) { @@ -354,7 +354,7 @@ public class NetworkChart: WidgetWrapper { Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_box", value: self.boxState) } - self.display() + self.renderToLayer() } @objc private func toggleHistoryCount(_ sender: NSMenuItem) { @@ -368,7 +368,7 @@ public class NetworkChart: WidgetWrapper { self.points = Array(repeating: (0, 0), count: num - self.points.count) + self.points } - self.display() + self.renderToLayer() } @objc private func toggleDownloadColor(_ sender: NSMenuItem) { @@ -377,7 +377,7 @@ public class NetworkChart: WidgetWrapper { self.downloadColor = newColor Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_downloadColor", value: newColor.key) } - self.display() + self.renderToLayer() } @objc private func toggleUploadColor(_ sender: NSMenuItem) { @@ -386,7 +386,7 @@ public class NetworkChart: WidgetWrapper { self.uploadColor = newColor Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_uploadColor", value: newColor.key) } - self.display() + self.renderToLayer() } @objc private func toggleScale(_ sender: NSMenuItem) { @@ -394,12 +394,12 @@ public class NetworkChart: WidgetWrapper { let value = Scale.allCases.first(where: { $0.key == key }) else { return } self.scaleState = value Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_scale", value: key) - self.display() + self.renderToLayer() } @objc private func toggleReverseOrder(_ sender: NSControl) { self.reverseOrderState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_reverseOrder", value: self.reverseOrderState) - self.display() + self.renderToLayer() } } diff --git a/Kit/Widgets/Speed.swift b/Kit/Widgets/Speed.swift index 05cce79f068..4bcc8fe315c 100644 --- a/Kit/Widgets/Speed.swift +++ b/Kit/Widgets/Speed.swift @@ -588,14 +588,14 @@ public class SpeedWidget: WidgetWrapper { self.displayModeView?.isEnabled = key.count > 1 Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_displayValue", value: key) - self.display() + self.renderToLayer() } @objc private func changeDisplayMode(_ sender: NSMenuItem) { guard let key = sender.representedObject as? String else { return } self.modeState = key Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_mode", value: key) - self.display() + self.renderToLayer() } @objc private func toggleValue(_ sender: NSControl) { @@ -604,13 +604,13 @@ public class SpeedWidget: WidgetWrapper { self.valueColorView?.isEnabled = self.valueState self.valueAlignmentView?.isEnabled = self.valueState Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_value", value: self.valueState) - self.display() + self.renderToLayer() } @objc private func toggleUnits(_ sender: NSControl) { self.unitsState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_units", value: self.unitsState) - self.display() + self.renderToLayer() } @objc private func toggleIcon(_ sender: NSMenuItem) { @@ -619,13 +619,13 @@ public class SpeedWidget: WidgetWrapper { self.iconColorView?.isEnabled = self.icon != "none" self.iconAlignmentView?.isEnabled = self.icon != "none" Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_icon", value: key) - self.display() + self.renderToLayer() } @objc private func toggleMonochrome(_ sender: NSControl) { self.monochromeState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_monochrome", value: self.monochromeState) - self.display() + self.renderToLayer() } @objc private func toggleValueColor(_ sender: NSMenuItem) { @@ -634,7 +634,7 @@ public class SpeedWidget: WidgetWrapper { self.valueColorState = newColor.key } Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_valueColor", value: key) - self.display() + self.renderToLayer() } @objc private func toggleOutputColor(_ sender: NSMenuItem) { @@ -668,18 +668,18 @@ public class SpeedWidget: WidgetWrapper { if updated { DispatchQueue.main.async(execute: { - self.display() + self.renderToLayer() }) } } - + @objc private func toggleValueAlignment(_ sender: NSMenuItem) { guard let key = sender.representedObject as? String else { return } if let newAlignment = Alignments.first(where: { $0.key == key }) { self.valueAlignmentState = newAlignment.key } Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_valueAlignment", value: key) - self.display() + self.renderToLayer() } @objc private func toggleIconAlignment(_ sender: NSMenuItem) { @@ -688,7 +688,7 @@ public class SpeedWidget: WidgetWrapper { self.iconAlignmentState = newAlignment.key } Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_iconAlignment", value: key) - self.display() + self.renderToLayer() } @objc private func toggleIconColor(_ sender: NSMenuItem) { @@ -697,6 +697,6 @@ public class SpeedWidget: WidgetWrapper { self.iconColorState = newColor.key } Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_iconColor", value: key) - self.display() + self.renderToLayer() } } diff --git a/Kit/Widgets/Stack.swift b/Kit/Widgets/Stack.swift index 7060c57dc3a..e613261a3b4 100644 --- a/Kit/Widgets/Stack.swift +++ b/Kit/Widgets/Stack.swift @@ -249,7 +249,7 @@ public class StackWidget: WidgetWrapper { if tableNeedsToBeUpdated { self.orderTableView.update() } - self.display() + self.renderToLayer() }) } @@ -291,19 +291,19 @@ public class StackWidget: WidgetWrapper { guard let key = sender.representedObject as? String else { return } self.modeState = StackMode(rawValue: key) ?? .auto Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_mode", value: key) - self.display() + self.renderToLayer() } @objc private func toggleSize(_ sender: NSControl) { self.fixedSizeState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_size", value: self.fixedSizeState) - self.display() + self.renderToLayer() } @objc private func toggleMonospacedFont(_ sender: NSControl) { self.monospacedFontState = controlState(sender) Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_monospacedFont", value: self.monospacedFontState) - self.display() + self.renderToLayer() } @objc private func toggleAlignment(_ sender: NSMenuItem) { @@ -312,7 +312,7 @@ public class StackWidget: WidgetWrapper { self.alignmentState = newAlignment.key } Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_alignment", value: key) - self.display() + self.renderToLayer() } } diff --git a/Kit/Widgets/Text.swift b/Kit/Widgets/Text.swift index bb661289a59..29371bd6bb4 100644 --- a/Kit/Widgets/Text.swift +++ b/Kit/Widgets/Text.swift @@ -71,7 +71,7 @@ public class TextWidget: WidgetWrapper { guard self.value != newValue else { return } self.value = newValue DispatchQueue.main.async(execute: { - self.display() + self.renderToLayer() }) } diff --git a/Kit/module/widget.swift b/Kit/module/widget.swift index 6ccdb6e03b1..9a65e36a668 100644 --- a/Kit/module/widget.swift +++ b/Kit/module/widget.swift @@ -11,6 +11,13 @@ import Cocoa +public extension NSStatusItem { + var hasValidBackingWindow: Bool { + guard let window = self.button?.window else { return false } + return window.windowNumber > 0 + } +} + public enum widget_t: String { case unknown = "" case mini = "mini" @@ -166,35 +173,95 @@ open class WidgetWrapper: NSView, widget_p { private var hasUsableHost: Bool { guard self.superview != nil else { return false } guard let window = self.window else { return false } - return window.windowNumber != -1 + return window.windowNumber > 0 } - + public init(_ type: widget_t, title: String, frame: NSRect) { self.type = type self.title = title self.shadowSize = frame.size self.queue = DispatchQueue(label: "eu.exelban.Stats.WidgetWrapper.\(type.rawValue).\(title)") - + super.init(frame: frame) + + self.wantsLayer = true + self.layer?.contentsGravity = .resize } - + required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + + open override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if self.window != nil { + self.renderToLayer() + } + } + + open override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + self.renderToLayer() + } + + public func renderToLayer() { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in self?.renderToLayer() } + return + } + guard let layer = self.layer else { return } + guard self.frame.width > 0, self.frame.height > 0 else { return } + + let scale = self.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 + let pixelWidth = Int((self.frame.width * scale).rounded()) + let pixelHeight = Int((self.frame.height * scale).rounded()) + guard pixelWidth > 0, pixelHeight > 0 else { return } + + guard let ctx = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return } + + ctx.scaleBy(x: scale, y: scale) + + let nsCtx = NSGraphicsContext(cgContext: ctx, flipped: self.isFlipped) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = nsCtx + + self.effectiveAppearance.performAsCurrentDrawingAppearance { + self.draw(self.bounds) + } + + NSGraphicsContext.restoreGraphicsState() + + guard let img = ctx.makeImage() else { return } + + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.contents = img + layer.contentsScale = scale + CATransaction.commit() + } + public func setWidth(_ width: CGFloat) { var newWidth = width if width == 0 || width == 1 { newWidth = self.emptyView() } - + guard self.shadowSize.width != newWidth else { return } self.shadowSize.width = newWidth - + DispatchQueue.main.async { guard self.hasUsableHost else { return } self.setFrameSize(NSSize(width: newWidth, height: self.frame.size.height)) self.widthHandler?() + self.renderToLayer() } } @@ -221,9 +288,12 @@ open class WidgetWrapper: NSView, widget_p { } public func redraw() { - DispatchQueue.main.async { [weak self] in - guard let self, self.hasUsableHost else { return } - self.display() + if Thread.isMainThread { + self.renderToLayer() + } else { + DispatchQueue.main.async { [weak self] in + self?.renderToLayer() + } } } @@ -290,7 +360,7 @@ public class SWidget { guard let s = self else { return } s.sizeCallback?() guard let item = s.menuBarItem, - item.button?.window != nil, + item.hasValidBackingWindow, item.length != s.item.frame.width else { return } item.length = s.item.frame.width } @@ -342,27 +412,36 @@ public class SWidget { if self.item.frame.origin.x != self.originX { self.item.setFrameOrigin(NSPoint(x: self.originX, y: self.item.frame.origin.y)) } - guard let button = self.menuBarItem?.button else { return } - if self.item.superview !== button { - self.item.removeFromSuperview() - button.addSubview(self.item) - } - button.image = NSImage() - button.toolTip = "\(localizedString(self.module)): \(self.type.name())" - - if let item = self.menuBarItem, !item.isVisible { - self.menuBarItem?.isVisible = true - } - - button.target = self - button.action = #selector(self.togglePopup) - button.sendAction(on: [.leftMouseDown, .rightMouseDown]) + self.attachToMenuBar(retries: 30) }) } else if let item = self.menuBarItem { NSStatusBar.system.removeStatusItem(item) self.menuBarItem = nil } } + + private func attachToMenuBar(retries: Int) { + guard let item = self.menuBarItem, let button = item.button else { return } + if !item.hasValidBackingWindow { + guard retries > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.attachToMenuBar(retries: retries - 1) + } + return + } + if self.item.superview !== button { + self.item.removeFromSuperview() + button.addSubview(self.item) + } + button.image = NSImage() + button.toolTip = "\(localizedString(self.module)): \(self.type.name())" + if !item.isVisible { + item.isVisible = true + } + button.target = self + button.action = #selector(self.togglePopup) + button.sendAction(on: [.leftMouseDown, .rightMouseDown]) + } @objc private func togglePopup() { if let item = self.menuBarItem, let window = item.button?.window { @@ -486,16 +565,7 @@ public class MenuBar { DispatchQueue.main.async(execute: { self.menuBarItem?.autosaveName = self.moduleName }) - self.menuBarItem?.isVisible = true - - self.menuBarItem?.button?.addSubview(self.view) - self.menuBarItem?.button?.image = NSImage() - self.menuBarItem?.button?.toolTip = "\(localizedString(self.moduleName))" - self.menuBarItem?.button?.target = self - self.menuBarItem?.button?.action = #selector(self.togglePopup) - self.menuBarItem?.button?.sendAction(on: [.leftMouseDown, .rightMouseDown]) - - self.recalculateWidth() + self.configureMenuBarButton(retries: 30) } else if let item = self.menuBarItem { saveNSStatusItemPosition(id: self.moduleName) NSStatusBar.system.removeStatusItem(item) @@ -503,10 +573,34 @@ public class MenuBar { } }) } + + private func configureMenuBarButton(retries: Int) { + guard let item = self.menuBarItem, let button = item.button else { return } + if !item.hasValidBackingWindow { + guard retries > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.configureMenuBarButton(retries: retries - 1) + } + return + } + if !item.isVisible { + item.isVisible = true + } + if self.view.superview !== button { + self.view.removeFromSuperview() + button.addSubview(self.view) + } + button.image = NSImage() + button.toolTip = "\(localizedString(self.moduleName))" + button.target = self + button.action = #selector(self.togglePopup) + button.sendAction(on: [.leftMouseDown, .rightMouseDown]) + self.recalculateWidth() + } private func recalculateWidth() { guard self.oneView, self.active else { return } - guard let item = self.menuBarItem, item.button?.window != nil else { return } + guard let item = self.menuBarItem, item.hasValidBackingWindow else { return } let w = self.activeWidgets.map({ $0.item.frame.width }).reduce(0, +) + (CGFloat(self.activeWidgets.count - 1) * Constants.Widget.spacing) + diff --git a/Kit/plugins/Charts.swift b/Kit/plugins/Charts.swift index e24813cbc00..d448b3eb66e 100644 --- a/Kit/plugins/Charts.swift +++ b/Kit/plugins/Charts.swift @@ -112,31 +112,89 @@ private func drawToolTip(_ frame: NSRect, _ point: CGPoint, _ size: CGSize, valu public class ChartView: NSView { public var id: String = UUID().uuidString fileprivate let stateQueue: DispatchQueue - + fileprivate init(frame: NSRect, queueLabel: String) { self.stateQueue = DispatchQueue(label: queueLabel, attributes: .concurrent) super.init(frame: frame) + self.wantsLayer = true + self.layer?.contentsGravity = .resize } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if self.window != nil { + self.renderToLayer() + } + } + + public override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + self.renderToLayer() + } + fileprivate func read(_ block: () -> T) -> T { self.stateQueue.sync(execute: block) } - + fileprivate func write(_ block: @escaping () -> Void) { self.stateQueue.async(flags: .barrier, execute: block) } - + + public func renderToLayer() { + if !Thread.isMainThread { + DispatchQueue.main.async { [weak self] in self?.renderToLayer() } + return + } + guard let layer = self.layer else { return } + guard self.frame.width > 0, self.frame.height > 0 else { return } + + let scale = self.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0 + let pixelWidth = Int((self.frame.width * scale).rounded()) + let pixelHeight = Int((self.frame.height * scale).rounded()) + guard pixelWidth > 0, pixelHeight > 0 else { return } + + guard let ctx = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return } + + ctx.scaleBy(x: scale, y: scale) + + let nsCtx = NSGraphicsContext(cgContext: ctx, flipped: self.isFlipped) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = nsCtx + + self.effectiveAppearance.performAsCurrentDrawingAppearance { + self.draw(self.bounds) + } + + NSGraphicsContext.restoreGraphicsState() + + guard let img = ctx.makeImage() else { return } + + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.contents = img + layer.contentsScale = scale + CATransaction.commit() + } + fileprivate func displayIfVisible() { if Thread.isMainThread { - if self.window?.isVisible ?? false { self.display() } + if self.window?.isVisible ?? false { self.renderToLayer() } } else { DispatchQueue.main.async { [weak self] in guard let self else { return } - if self.window?.isVisible ?? false { self.display() } + if self.window?.isVisible ?? false { self.renderToLayer() } } } } @@ -616,25 +674,25 @@ public class LineChartView: ChartView { public override func mouseEntered(with event: NSEvent) { guard self.tooltipEnabledSnapshot else { return } self.cursor = convert(event.locationInWindow, from: nil) - self.needsDisplay = true + self.renderToLayer() } public override func mouseMoved(with event: NSEvent) { guard self.tooltipEnabledSnapshot else { return } self.cursor = convert(event.locationInWindow, from: nil) - self.needsDisplay = true + self.renderToLayer() } public override func mouseDragged(with event: NSEvent) { guard self.tooltipEnabledSnapshot else { return } self.cursor = convert(event.locationInWindow, from: nil) - self.needsDisplay = true + self.renderToLayer() } public override func mouseExited(with event: NSEvent) { guard self.tooltipEnabledSnapshot else { return } self.cursor = nil - self.needsDisplay = true + self.renderToLayer() } public override func mouseDown(with: NSEvent) { @@ -1155,19 +1213,19 @@ public class ColumnChartView: ChartView { public override func mouseEntered(with event: NSEvent) { self.cursor = convert(event.locationInWindow, from: nil) - self.display() + self.renderToLayer() } public override func mouseMoved(with event: NSEvent) { self.cursor = convert(event.locationInWindow, from: nil) - self.display() + self.renderToLayer() } public override func mouseDragged(with event: NSEvent) { self.cursor = convert(event.locationInWindow, from: nil) - self.display() + self.renderToLayer() } public override func mouseExited(with event: NSEvent) { self.cursor = nil - self.display() + self.renderToLayer() } public override func updateTrackingAreas() { @@ -1275,17 +1333,17 @@ public class GridChartView: ChartView { public override func mouseEntered(with event: NSEvent) { self.cursor = convert(event.locationInWindow, from: nil) - self.needsDisplay = true + self.renderToLayer() } public override func mouseMoved(with event: NSEvent) { self.cursor = convert(event.locationInWindow, from: nil) - self.needsDisplay = true + self.renderToLayer() } public override func mouseExited(with event: NSEvent) { self.cursor = nil - self.needsDisplay = true + self.renderToLayer() } public override func updateTrackingAreas() { diff --git a/Stats/Views/CombinedView.swift b/Stats/Views/CombinedView.swift index 36c7eba24cd..550f435c0e9 100644 --- a/Stats/Views/CombinedView.swift +++ b/Stats/Views/CombinedView.swift @@ -73,10 +73,25 @@ internal class CombinedView: NSObject, NSGestureRecognizerDelegate { DispatchQueue.main.async(execute: { self.menuBarItem?.autosaveName = "CombinedModules" }) - self.menuBarItem?.button?.addSubview(self.view) - self.menuBarItem?.button?.image = NSImage() - self.menuBarItem?.button?.toolTip = localizedString("Combined modules") - + self.configureMenuBarButton(retries: 30) + } + + private func configureMenuBarButton(retries: Int) { + guard let item = self.menuBarItem, let button = item.button else { return } + if !item.hasValidBackingWindow { + guard retries > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.configureMenuBarButton(retries: retries - 1) + } + return + } + if self.view.superview !== button { + self.view.removeFromSuperview() + button.addSubview(self.view) + } + button.image = NSImage() + button.toolTip = localizedString("Combined modules") + if !self.combinedModulesPopup { self.activeModules.forEach { (m: Module) in m.menuBar.widgets.forEach { w in @@ -93,11 +108,11 @@ internal class CombinedView: NSObject, NSGestureRecognizerDelegate { } } } else { - self.menuBarItem?.button?.target = self - self.menuBarItem?.button?.action = #selector(self.togglePopup) - self.menuBarItem?.button?.sendAction(on: [.leftMouseDown, .rightMouseDown]) + button.target = self + button.action = #selector(self.togglePopup) + button.sendAction(on: [.leftMouseDown, .rightMouseDown]) } - + DispatchQueue.main.async(execute: { self.recalculate() }) @@ -136,7 +151,9 @@ internal class CombinedView: NSObject, NSGestureRecognizerDelegate { } } self.view.setFrameSize(NSSize(width: w, height: self.view.frame.height)) - self.menuBarItem?.length = w + if let item = self.menuBarItem, item.hasValidBackingWindow, item.length != w { + item.length = w + } } // call when popup appear/disappear From f2e55e37946301495bddd63df8a9ab6836266f41 Mon Sep 17 00:00:00 2001 From: Adam Conway Date: Sun, 17 May 2026 13:02:51 +0100 Subject: [PATCH 3/3] fix: tighten widget layer-rendering pipeline Four fixes to the layer-contents pipeline: - Override viewDidChangeBackingProperties on WidgetWrapper to call renderToLayer(). Without this the cached layer.contents image stays at its previous scale when the widget moves between displays of different DPI (like disconnecting a Retina external), leaving widgets blurry or oversharp until the next setValue tick refreshes them. - Set layerContentsRedrawPolicy = .never. Without this the default .duringViewResize policy lets AppKit autonomously call draw(_:) on view geometry changes (which happen often when text widgets re-measure on each value update), going through setNeedsDisplayInRect: and re-triggering the notification chain renderToLayer is meant to bypass. Setting .never tells AppKit the application owns layer contents entirely. - Switch contentsGravity from .resize to .center. The transient frame between a resize and the next renderToLayer call is normally a single frame, but .resize stretches the cached image to fill the new bounds, which looks obviously wrong for text widgets during that frame. .center shows the old image at natural size (possibly clipped) which reads correctly. - Switch the CGContext pixel format from premultipliedLast (RGBA) to premultipliedFirst + byteOrder32Little (BGRA). BGRA is the native pixel layout for CoreGraphics and CoreAnimation on Apple platforms, so handing the CGImage to layer.contents no longer requires a swizzle pass before GPU upload. The perf difference is in the low microseconds for a status bar widget redrawing at ~1 Hz so it's not measurable in practice, but it matches the native pixel layout used by IOSurface and CoreAnimation. --- Kit/module/widget.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Kit/module/widget.swift b/Kit/module/widget.swift index 9a65e36a668..0a97fe678e0 100644 --- a/Kit/module/widget.swift +++ b/Kit/module/widget.swift @@ -185,7 +185,8 @@ open class WidgetWrapper: NSView, widget_p { super.init(frame: frame) self.wantsLayer = true - self.layer?.contentsGravity = .resize + self.layerContentsRedrawPolicy = .never + self.layer?.contentsGravity = .center } required public init?(coder: NSCoder) { @@ -204,6 +205,11 @@ open class WidgetWrapper: NSView, widget_p { self.renderToLayer() } + open override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + self.renderToLayer() + } + public func renderToLayer() { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.renderToLayer() } @@ -224,7 +230,7 @@ open class WidgetWrapper: NSView, widget_p { bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + bitmapInfo: CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue ) else { return } ctx.scaleBy(x: scale, y: scale)