Files
macos-stats/Kit/Widgets/BarChart.swift
Andy Chong a69627f135 fix: fixed race condition crashes in Chart and Stack widgets (#2944)
Ensure data mutations from background readers are dispatched to the main thread to prevent conflicts with drawing logic (which runs on main thread). This resolves SIGTRAP and SIGABRT crashes observed during extended runtime.
2026-02-01 17:39:00 +01:00

330 lines
12 KiB
Swift

//
// BarChart.swift
// Kit
//
// Created by Serhiy Mytrovtsiy on 26/04/2020.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2020 Serhiy Mytrovtsiy. All rights reserved.
//
import Cocoa
public class BarChart: WidgetWrapper {
private var labelState: Bool = false
private var boxState: Bool = true
private var frameState: Bool = false
public var colorState: SColor = .systemAccent
private var colors: [SColor] = SColor.allCases
private var _value: [[ColorValue]] = [[]]
private var _pressureLevel: RAMPressure = .normal
private var _colorZones: colorZones = (0.6, 0.8)
private var boxSettingsView: NSSwitch? = nil
private var frameSettingsView: NSSwitch? = nil
public var NSLabelCharts: [NSAttributedString] = []
public init(title: String, config: NSDictionary?, preview: Bool = false) {
var widgetTitle: String = title
if config != nil {
var configuration = config!
if let titleFromConfig = config!["Title"] as? String {
widgetTitle = titleFromConfig
}
if preview {
if let previewConfig = config!["Preview"] as? NSDictionary {
configuration = previewConfig
if let value = configuration["Value"] as? String {
self._value = value.split(separator: ",").map{ ([ColorValue(Double($0) ?? 0)]) }
}
}
}
if let label = configuration["Label"] as? Bool {
self.labelState = label
}
if let box = configuration["Box"] as? Bool {
self.boxState = box
}
if let unsupportedColors = configuration["Unsupported colors"] as? [String] {
self.colors = self.colors.filter{ !unsupportedColors.contains($0.key) }
}
if let color = configuration["Color"] as? String {
if let defaultColor = self.colors.first(where: { "\($0.self)" == color }) {
self.colorState = defaultColor
}
}
}
super.init(.barChart, title: widgetTitle, frame: CGRect(
x: Constants.Widget.margin.x,
y: Constants.Widget.margin.y,
width: Constants.Widget.width + (2*Constants.Widget.margin.x),
height: Constants.Widget.height - (2*Constants.Widget.margin.y)
))
self.canDrawConcurrently = true
if !preview {
self.boxState = Store.shared.bool(key: "\(self.title)_\(self.type.rawValue)_box", defaultValue: self.boxState)
self.frameState = Store.shared.bool(key: "\(self.title)_\(self.type.rawValue)_frame", defaultValue: self.frameState)
self.labelState = Store.shared.bool(key: "\(self.title)_\(self.type.rawValue)_label", defaultValue: self.labelState)
self.colorState = SColor.fromString(Store.shared.string(key: "\(self.title)_\(self.type.rawValue)_color", defaultValue: self.colorState.key))
}
if preview {
if self._value.isEmpty {
self._value = [[ColorValue(0.72)], [ColorValue(0.38)]]
}
self.setFrameSize(NSSize(width: 36, height: self.frame.size.height))
self.invalidateIntrinsicContentSize()
self.display()
}
let style = NSMutableParagraphStyle()
style.alignment = .center
let stringAttributes = [
NSAttributedString.Key.font: NSFont.systemFont(ofSize: 7, weight: .regular),
NSAttributedString.Key.foregroundColor: NSColor.textColor,
NSAttributedString.Key.paragraphStyle: style
]
for char in String(self.title.prefix(3)).uppercased().reversed() {
let str = NSAttributedString.init(string: "\(char)", attributes: stringAttributes)
self.NSLabelCharts.append(str)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
var value: [[ColorValue]] = []
var pressureLevel: RAMPressure = .normal
var colorZones: colorZones = (0.6, 0.8)
self.queue.sync {
value = self._value
pressureLevel = self._pressureLevel
colorZones = self._colorZones
}
guard !value.isEmpty else {
self.setWidth(0)
return
}
var width: CGFloat = Constants.Widget.margin.x*2
var x: CGFloat = 0
let lineWidth = 1 / (NSScreen.main?.backingScaleFactor ?? 1)
let offset = lineWidth / 2
switch value.count {
case 0, 1:
width += 10 + (offset*2)
case 2:
width += 22
case 3...4: // 3,4
width += 30
case 5...8: // 5,6,7,8
width += 40
case 9...12: // 9..12
width += 50
case 13...16: // 13..16
width += 76
case 17...32: // 17..32
width += 84
default: // > 32
width += 118
}
if self.labelState {
let letterHeight = self.frame.height / 3
let letterWidth: CGFloat = 6.0
var yMargin: CGFloat = 0
for char in self.NSLabelCharts {
let rect = CGRect(x: x, y: yMargin, width: letterWidth, height: letterHeight)
char.draw(with: rect)
yMargin += letterHeight
}
width += letterWidth + Constants.Widget.spacing
x = letterWidth + Constants.Widget.spacing
}
let box = NSBezierPath(roundedRect: NSRect(
x: x + offset,
y: offset,
width: width - x - (offset*2) - (Constants.Widget.margin.x*2),
height: self.frame.size.height - (offset*2)
), xRadius: 2, yRadius: 2)
if self.boxState {
(isDarkMode ? NSColor.white : NSColor.black).set()
box.stroke()
box.fill()
}
let widthForBarChart = box.bounds.width
let partitionMargin: CGFloat = 0.5
let partitionsMargin: CGFloat = (CGFloat(value.count - 1)) * partitionMargin / CGFloat(value.count - 1)
let partitionWidth: CGFloat = (widthForBarChart / CGFloat(value.count)) - CGFloat(partitionsMargin.isNaN ? 0 : partitionsMargin)
let maxPartitionHeight: CGFloat = box.bounds.height
x += offset
for i in 0..<value.count {
var y = offset
for a in 0..<value[i].count {
let partitionValue = value[i][a]
let partitionHeight = maxPartitionHeight * CGFloat(partitionValue.value)
let partition = NSBezierPath(rect: NSRect(x: x, y: y, width: partitionWidth, height: partitionHeight))
if partitionValue.color == nil {
switch self.colorState {
case .systemAccent: NSColor.controlAccentColor.set()
case .utilization: partitionValue.value.usageColor(zones: colorZones, reversed: self.title == "Battery").set()
case .pressure: pressureLevel.pressureColor().set()
case .monochrome:
if self.boxState {
(isDarkMode ? NSColor.black : NSColor.white).set()
} else {
(isDarkMode ? NSColor.white : NSColor.black).set()
}
default: (self.colorState.additional as? NSColor ?? .controlAccentColor).set()
}
} else {
partitionValue.color?.set()
}
partition.fill()
partition.close()
y += partitionHeight
}
x += partitionWidth + partitionMargin
}
if self.boxState || self.frameState {
(isDarkMode ? NSColor.white : NSColor.black).set()
box.lineWidth = lineWidth
box.stroke()
}
self.setWidth(width)
}
public func setValue(_ newValue: [[ColorValue]]) {
DispatchQueue.main.async(execute: {
let tolerance: CGFloat = 0.01
let isDifferent = self._value.count != newValue.count || zip(self._value, newValue).contains { row1, row2 in
row1.count != row2.count || zip(row1, row2).contains { val1, val2 in
abs(val1.value - val2.value) > tolerance || val1.color != val2.color
}
}
guard isDifferent else { return }
self._value = newValue
self.redraw()
})
}
public func setPressure(_ newPressureLevel: RAMPressure) {
DispatchQueue.main.async(execute: {
guard self._pressureLevel != newPressureLevel else { return }
self._pressureLevel = newPressureLevel
self.redraw()
})
}
public func setColorZones(_ newColorZones: colorZones) {
DispatchQueue.main.async(execute: {
guard self._colorZones != newColorZones else { return }
self._colorZones = newColorZones
self.redraw()
})
}
// MARK: - Settings
public override func settings() -> NSView {
let view = SettingsContainerView()
let box = switchView(
action: #selector(self.toggleBox),
state: self.boxState
)
self.boxSettingsView = box
let frame = switchView(
action: #selector(self.toggleFrame),
state: self.frameState
)
self.frameSettingsView = frame
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Label"), component: switchView(
action: #selector(self.toggleLabel),
state: self.labelState
)),
PreferencesRow(localizedString("Color"), component: selectView(
action: #selector(self.toggleColor),
items: self.colors,
selected: self.colorState.key
)),
PreferencesRow(localizedString("Box"), component: box),
PreferencesRow(localizedString("Frame"), component: frame)
]))
return view
}
@objc private func toggleLabel(_ sender: NSControl) {
self.labelState = controlState(sender)
Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_label", value: self.labelState)
self.redraw()
}
@objc private func toggleBox(_ sender: NSControl) {
self.boxState = controlState(sender)
Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_box", value: self.boxState)
if self.frameState {
self.frameSettingsView?.state = .off
self.frameState = false
Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_frame", value: self.frameState)
}
self.redraw()
}
@objc private func toggleFrame(_ sender: NSControl) {
self.frameState = controlState(sender)
Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_frame", value: self.frameState)
if self.boxState {
self.boxSettingsView?.state = .off
self.boxState = false
Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_box", value: self.boxState)
}
self.redraw()
}
@objc private func toggleColor(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String else { return }
if let newColor = self.colors.first(where: { $0.key == key }) {
self.colorState = newColor
}
Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_color", value: key)
self.redraw()
}
}