Files
macos-stats/Kit/Widgets/LineChart.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

388 lines
14 KiB
Swift

//
// Chart.swift
// Kit
//
// Created by Serhiy Mytrovtsiy on 18/04/2020.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2020 Serhiy Mytrovtsiy. All rights reserved.
//
import Cocoa
public class LineChart: WidgetWrapper {
private var labelState: Bool = false
private var boxState: Bool = true
private var frameState: Bool = false
private var valueState: Bool = false
private var valueColorState: Bool = false
private var colorState: SColor = .systemAccent
private var historyCount: Int = 60
private var scaleState: Scale = .none
private var chart: LineChartView = LineChartView(frame: NSRect(
x: 0,
y: 0,
width: 32,
height: Constants.Widget.height - (2*Constants.Widget.margin.y)
), num: 60)
private var colors: [SColor] = SColor.allCases.filter({ $0 != SColor.cluster })
private var _value: Double = 0
private var _pressureLevel: RAMPressure = .normal
private var historyNumbers: [KeyValue_p] = [
KeyValue_t(key: "30", value: "30"),
KeyValue_t(key: "60", value: "60"),
KeyValue_t(key: "90", value: "90"),
KeyValue_t(key: "120", value: "120")
]
private var width: CGFloat {
get {
switch self.historyCount {
case 30:
return 24
case 60:
return 32
case 90:
return 42
case 120:
return 52
default:
return 32
}
}
}
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 {
if let titleFromConfig = config!["Title"] as? String {
widgetTitle = titleFromConfig
}
if let label = config!["Label"] as? Bool {
self.labelState = label
}
if let box = config!["Box"] as? Bool {
self.boxState = box
}
if let value = config!["Value"] as? Bool {
self.valueState = value
}
if let unsupportedColors = config!["Unsupported colors"] as? [String] {
self.colors = self.colors.filter{ !unsupportedColors.contains($0.key) }
}
if let color = config!["Color"] as? String {
if let defaultColor = colors.first(where: { "\($0.self)" == color }) {
self.colorState = defaultColor
}
}
}
super.init(.lineChart, title: widgetTitle, frame: CGRect(
x: Constants.Widget.margin.x,
y: Constants.Widget.margin.y,
width: 32 + (Constants.Widget.margin.x*2),
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.valueState = Store.shared.bool(key: "\(self.title)_\(self.type.rawValue)_value", defaultValue: self.valueState)
self.labelState = Store.shared.bool(key: "\(self.title)_\(self.type.rawValue)_label", defaultValue: self.labelState)
self.valueColorState = Store.shared.bool(key: "\(self.title)_\(self.type.rawValue)_valueColor", defaultValue: self.valueColorState)
self.colorState = SColor.fromString(Store.shared.string(key: "\(self.title)_\(self.type.rawValue)_color", defaultValue: self.colorState.key))
self.historyCount = Store.shared.int(key: "\(self.title)_\(self.type.rawValue)_historyCount", defaultValue: self.historyCount)
self.scaleState = Scale.fromString(Store.shared.string(key: "\(self.title)_\(self.type.rawValue)_scale", defaultValue: self.scaleState.key))
self.chart.setScale(self.scaleState)
self.chart.reinit(self.historyCount)
}
if self.labelState {
self.setFrameSize(NSSize(width: Constants.Widget.width + 6 + (Constants.Widget.margin.x*2), height: self.frame.size.height))
}
if preview {
var list: [DoubleValue] = []
for _ in 0..<16 {
list.append(DoubleValue(Double.random(in: 0..<1)))
}
self.chart.points = list
self._value = 0.38
}
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)
guard let context = NSGraphicsContext.current?.cgContext else { return }
var value: Double = 0
var pressureLevel: RAMPressure = .normal
self.queue.sync {
value = self._value
pressureLevel = self._pressureLevel
}
var width = self.width + (Constants.Widget.margin.x*2)
var x: CGFloat = 0
let lineWidth = 1 / (NSScreen.main?.backingScaleFactor ?? 1)
let offset = lineWidth / 2
var boxSize: CGSize = CGSize(width: self.width - (Constants.Widget.margin.x*2), height: self.frame.size.height)
var color: NSColor = .controlAccentColor
switch self.colorState {
case .systemAccent: color = .controlAccentColor
case .utilization: color = value.usageColor()
case .pressure: color = pressureLevel.pressureColor()
case .monochrome:
if self.boxState {
color = (isDarkMode ? NSColor.black : NSColor.white)
} else {
color = (isDarkMode ? NSColor.white : NSColor.black)
}
default: color = self.colorState.additional as? NSColor ?? .controlAccentColor
}
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
}
if self.valueState {
let style = NSMutableParagraphStyle()
style.alignment = .right
var valueColor = isDarkMode ? NSColor.white : NSColor.black
if self.valueColorState {
valueColor = color
}
let stringAttributes = [
NSAttributedString.Key.font: NSFont.systemFont(ofSize: 8, weight: .regular),
NSAttributedString.Key.foregroundColor: valueColor,
NSAttributedString.Key.paragraphStyle: style
]
let rect = CGRect(x: x+2, y: boxSize.height-7, width: boxSize.width - 2, height: 7)
let str = NSAttributedString.init(string: "\(Int((value.rounded(toPlaces: 2)) * 100))%", attributes: stringAttributes)
str.draw(with: rect)
boxSize.height = offset == 0.5 ? 10 : 9
}
let box = NSBezierPath(roundedRect: NSRect(
x: x+offset,
y: offset,
width: self.width - offset*2,
height: boxSize.height - (offset*2)
), xRadius: 2, yRadius: 2)
if self.boxState {
(isDarkMode ? NSColor.white : NSColor.black).set()
box.stroke()
box.fill()
self.chart.transparent = false
} else if self.frameState {
self.chart.transparent = true
} else {
self.chart.transparent = true
}
context.saveGState()
let chartFrame = NSRect(
x: x+offset+lineWidth,
y: offset,
width: box.bounds.width - (offset*2+lineWidth),
height: box.bounds.height - offset
)
self.chart.color = color
self.chart.setFrameSize(NSSize(width: chartFrame.width, height: chartFrame.height))
self.chart.draw(chartFrame)
context.restoreGState()
if self.boxState || self.frameState {
(isDarkMode ? NSColor.white : NSColor.black).set()
box.lineWidth = lineWidth
box.stroke()
}
self.setWidth(width)
}
public func setValue(_ newValue: Double) {
DispatchQueue.main.async(execute: {
self._value = newValue
self.chart.addValue(newValue)
self.display()
})
}
public func setPressure(_ newPressureLevel: RAMPressure) {
DispatchQueue.main.async(execute: {
guard self._pressureLevel != newPressureLevel else { return }
self._pressureLevel = newPressureLevel
self.display()
})
}
// 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("Value"), component: switchView(
action: #selector(self.toggleValue),
state: self.valueState
)),
PreferencesRow(localizedString("Box"), component: box),
PreferencesRow(localizedString("Frame"), component: frame),
PreferencesRow(localizedString("Color"), component: selectView(
action: #selector(self.toggleColor),
items: self.colors,
selected: self.colorState.key
)),
PreferencesRow(localizedString("Colorize value"), component: switchView(
action: #selector(self.toggleValueColor),
state: self.valueColorState
)),
PreferencesRow(localizedString("Number of reads in the chart"), component: selectView(
action: #selector(self.toggleHistoryCount),
items: self.historyNumbers,
selected: "\(self.historyCount)"
)),
PreferencesRow(localizedString("Scaling"), component: selectView(
action: #selector(self.toggleScale),
items: Scale.allCases.filter({ $0 != .fixed }),
selected: self.scaleState.key
))
]))
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.display()
}
@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.display()
}
@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.display()
}
@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()
}
@objc private func toggleColor(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String else { return }
if let newColor = SColor.allCases.first(where: { $0.key == key }) {
self.colorState = newColor
}
Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_color", value: key)
self.display()
}
@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()
}
@objc private func toggleHistoryCount(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String, let value = Int(key) else { return }
self.historyCount = value
Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_historyCount", value: value)
self.chart.reinit(value)
self.display()
}
@objc private func toggleScale(_ sender: NSMenuItem) {
guard let key = sender.representedObject as? String,
let value = Scale.allCases.first(where: { $0.key == key }) else { return }
self.scaleState = value
self.chart.setScale(value)
Store.shared.set(key: "\(self.title)_\(self.type.rawValue)_scale", value: key)
self.display()
}
}