Files
macos-stats/Kit/module/widget.swift

580 lines
21 KiB
Swift

//
// widget.swift
// Kit
//
// Created by Serhiy Mytrovtsiy on 10/04/2020.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2020 Serhiy Mytrovtsiy. All rights reserved.
//
import Cocoa
public enum widget_t: String {
case unknown = ""
case mini = "mini"
case lineChart = "line_chart"
case barChart = "bar_chart"
case pieChart = "pie_chart"
case networkChart = "network_chart"
case speed = "speed"
case battery = "battery"
case batteryDetails = "battery_details"
case stack = "sensors" // to replace
case memory = "memory"
case label = "label"
case tachometer = "tachometer"
case state = "state"
case text = "text"
public func new(module: String, config: NSDictionary, defaultWidget: widget_t) -> SWidget? {
guard let widgetConfig: NSDictionary = config[self.rawValue] as? NSDictionary else { return nil }
var image: NSImage? = nil
var preview: widget_p? = nil
var item: widget_p? = nil
switch self {
case .mini:
preview = Mini(title: module, config: widgetConfig, preview: true)
item = Mini(title: module, config: widgetConfig, preview: false)
case .lineChart:
preview = LineChart(title: module, config: widgetConfig, preview: true)
item = LineChart(title: module, config: widgetConfig, preview: false)
case .barChart:
preview = BarChart(title: module, config: widgetConfig, preview: true)
item = BarChart(title: module, config: widgetConfig, preview: false)
case .pieChart:
preview = PieChart(title: module, config: widgetConfig, preview: true)
item = PieChart(title: module, config: widgetConfig, preview: false)
case .networkChart:
preview = NetworkChart(title: module, config: widgetConfig, preview: true)
item = NetworkChart(title: module, config: widgetConfig, preview: false)
case .speed:
preview = SpeedWidget(title: module, config: widgetConfig, preview: true)
item = SpeedWidget(title: module, config: widgetConfig, preview: false)
case .battery:
preview = BatteryWidget(title: module, preview: true)
item = BatteryWidget(title: module, preview: false)
case .batteryDetails:
preview = BatteryDetailsWidget(title: module, preview: true)
item = BatteryDetailsWidget(title: module, preview: false)
case .stack:
preview = StackWidget(title: module, config: widgetConfig, preview: true)
item = StackWidget(title: module, config: widgetConfig, preview: false)
case .memory:
preview = MemoryWidget(title: module, config: widgetConfig, preview: true)
item = MemoryWidget(title: module, config: widgetConfig, preview: false)
case .label:
preview = Label(title: module, config: widgetConfig)
item = Label(title: module, config: widgetConfig)
case .tachometer:
preview = Tachometer(title: module, preview: true)
item = Tachometer(title: module, preview: false)
case .state:
preview = StateWidget(title: module, config: widgetConfig, preview: true)
item = StateWidget(title: module, config: widgetConfig, preview: false)
case .text:
preview = TextWidget(title: module, config: widgetConfig, preview: true)
item = TextWidget(title: module, config: widgetConfig, preview: false)
default: break
}
if let view = preview {
var width: CGFloat = view.bounds.width
switch preview {
case is Mini:
if module == "Battery" {
width = view.bounds.width + 3
}
case is BarChart:
if module == "GPU" || module == "RAM" || module == "Disk" || module == "Battery" {
width = 11 + (Constants.Widget.margin.x*2)
} else if module == "Sensors" {
width = 22 + (Constants.Widget.margin.x*2)
} else if module == "CPU" {
width = 30 + (Constants.Widget.margin.x*2)
}
case is StackWidget:
if module == "Sensors" {
width = 25
} else if module == "Clock" {
width = 114
}
case is MemoryWidget:
width = view.bounds.width + 8 + Constants.Widget.spacing*2
case is BatteryWidget:
width = view.bounds.width - 3
default: width = view.bounds.width
}
let r = NSRect(
x: -view.frame.origin.x/2,
y: 0,
width: width - view.frame.origin.x,
height: view.bounds.height
)
image = NSImage(data: view.dataWithPDF(inside: r))
}
if let item = item, let image = image {
return SWidget(self, defaultWidget: defaultWidget, module: module, item: item, image: image)
}
return nil
}
public func name() -> String {
switch self {
case .mini: return localizedString("Mini widget")
case .lineChart: return localizedString("Line chart widget")
case .barChart: return localizedString("Bar chart widget")
case .pieChart: return localizedString("Pie chart widget")
case .networkChart: return localizedString("Network chart widget")
case .speed: return localizedString("Speed widget")
case .battery: return localizedString("Battery widget")
case .batteryDetails: return localizedString("Battery details widget")
case .stack: return localizedString("Stack widget")
case .memory: return localizedString("Memory widget")
case .label: return localizedString("Label widget")
case .tachometer: return localizedString("Tachometer widget")
case .state: return localizedString("State widget")
case .text: return localizedString("Text widget")
default: return ""
}
}
}
extension widget_t: CaseIterable {}
public protocol widget_p: NSView {
var widthHandler: (() -> Void)? { get set }
var onClick: (() -> Void)? { get set }
func settings() -> NSView
}
open class WidgetWrapper: NSView, widget_p {
public var type: widget_t
public var title: String
public var widthHandler: (() -> Void)? = nil
public var onClick: (() -> Void)? = nil
public var shadowSize: CGSize
internal var queue: DispatchQueue
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)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
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 {
self.setFrameSize(NSSize(width: newWidth, height: self.frame.size.height))
self.widthHandler?()
}
}
public func emptyView() -> CGFloat {
let size: CGFloat = 15
let lineWidth = 1 / (NSScreen.main?.backingScaleFactor ?? 1)
let offset = lineWidth / 2
let width: CGFloat = (Constants.Widget.margin.x*2) + size + (lineWidth*2)
NSColor.textColor.set()
var circle = NSBezierPath()
circle = NSBezierPath(ovalIn: CGRect(x: Constants.Widget.margin.x+offset, y: 1+offset, width: size, height: size))
circle.stroke()
circle.lineWidth = lineWidth
let line = NSBezierPath()
line.move(to: NSPoint(x: 3, y: 3.5))
line.line(to: NSPoint(x: 13.5, y: 14))
line.lineWidth = lineWidth
line.stroke()
return width
}
public func redraw() {
DispatchQueue.main.async { [weak self] in
self?.display()
}
}
open func settings() -> NSView { return NSView() }
open override func mouseDown(with event: NSEvent) {
if let f = self.onClick {
f()
return
}
super.mouseDown(with: event)
}
}
public class SWidget {
public let type: widget_t
public let defaultWidget: widget_t
public let module: String
public let image: NSImage
public var item: widget_p
public var isActive: Bool {
get { self.list.contains{ $0 == self.type } }
set {
if newValue {
self.list.append(self.type)
} else {
self.list.removeAll{ $0 == self.type }
}
}
}
public var toggleCallback: ((widget_t, Bool) -> Void)? = nil
public var sizeCallback: (() -> Void)? = nil
public var log: NextLog {
NextLog.shared.copy(category: self.module)
}
public var position: Int {
get { Store.shared.int(key: "\(self.module)_\(self.type)_position", defaultValue: 0) }
set { Store.shared.set(key: "\(self.module)_\(self.type)_position", value: newValue) }
}
private var list: [widget_t] {
get {
let string = Store.shared.string(key: "\(self.module)_widget", defaultValue: self.defaultWidget.rawValue)
return string.split(separator: ",").map{ (widget_t(rawValue: String($0)) ?? .unknown)}
}
set { Store.shared.set(key: "\(self.module)_widget", value: newValue.map{ $0.rawValue }.joined(separator: ",")) }
}
private var menuBarItem: NSStatusItem? = nil
private var originX: CGFloat
public init(_ type: widget_t, defaultWidget: widget_t, module: String, item: widget_p, image: NSImage) {
self.type = type
self.module = module
self.item = item
self.defaultWidget = defaultWidget
self.image = image
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
}
}
self.item.identifier = NSUserInterfaceItemIdentifier(self.type.rawValue)
}
// show item in the menu bar
public func enable() {
guard self.isActive else { return }
self.toggleCallback?(self.type, true)
debug("widget \(self.type.rawValue) enabled", log: self.log)
}
// remove item from the menu bar
public func disable() {
self.toggleCallback?(self.type, false)
debug("widget \(self.type.rawValue) disabled", log: self.log)
}
// toggle the widget
public func toggle(_ state: Bool? = nil) {
var newState: Bool = !self.isActive
if let state = state {
newState = state
}
if self.isActive == newState {
return
}
self.isActive = newState
if !self.isActive {
self.disable()
} else {
self.enable()
}
NotificationCenter.default.post(name: .toggleWidget, object: nil, userInfo: ["module": self.module])
}
public func setMenuBarItem(state: Bool) {
if state {
DispatchQueue.main.async(execute: {
self.menuBarItem = NSStatusBar.system.statusItem(withLength: self.item.frame.width)
DispatchQueue.main.async(execute: {
self.menuBarItem?.autosaveName = "\(self.module)_\(self.type.rawValue)"
})
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())"
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])
})
} else if let item = self.menuBarItem {
NSStatusBar.system.removeStatusItem(item)
self.menuBarItem = nil
}
}
@objc private func togglePopup() {
if let item = self.menuBarItem, let window = item.button?.window {
NotificationCenter.default.post(name: .togglePopup, object: nil, userInfo: [
"module": self.module,
"widget": self.type,
"origin": window.frame.origin,
"center": window.frame.width/2
])
}
}
}
public class MenuBar {
public var callback: (() -> Void)? = nil
public var widgets: [SWidget] = []
private var moduleName: String
private var menuBarItem: NSStatusItem? = nil
private var queue: DispatchQueue
private var combinedModules: Bool {
Store.shared.bool(key: "CombinedModules", defaultValue: false)
}
public var view: MenuBarView = MenuBarView()
public var oneView: Bool = false
public var activeWidgets: [SWidget] {
self.widgets.filter({ $0.isActive })
}
public var sortedWidgets: [widget_t] {
get {
var list: [widget_t: Int] = [:]
self.activeWidgets.forEach { (w: SWidget) in
list[w.type] = w.position
}
return list.sorted { $0.1 < $1.1 }.map{ $0.key }
}
}
private var _active: Bool = false
public var active: Bool {
get { self.queue.sync { self._active } }
set { self.queue.sync { self._active = newValue } }
}
init(moduleName: String) {
self.moduleName = moduleName
self.queue = DispatchQueue(label: "eu.exelban.Stats.MenuBar.\(moduleName)")
self.oneView = Store.shared.bool(key: "\(self.moduleName)_oneView", defaultValue: self.oneView)
self.view.identifier = NSUserInterfaceItemIdentifier(rawValue: moduleName)
if self.combinedModules {
self.oneView = true
} else {
self.setupMenuBarItem(self.oneView)
}
NotificationCenter.default.addObserver(self, selector: #selector(listenForOneView), name: .toggleOneView, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(listenForWidgetRearrange), name: .widgetRearrange, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self, name: .toggleOneView, object: nil)
NotificationCenter.default.removeObserver(self, name: .widgetRearrange, object: nil)
}
public func append(_ widget: SWidget) {
widget.toggleCallback = { [weak self] (type, state) in
if let s = self, s.oneView {
if state, let w = s.activeWidgets.first(where: { $0.type == type }) {
DispatchQueue.main.async(execute: {
s.recalculateWidth()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
s.view.addWidget(w.item)
s.view.recalculate(s.sortedWidgets)
}
})
} else {
DispatchQueue.main.async(execute: {
s.view.removeWidget(type: type)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
s.recalculateWidth()
s.view.recalculate(s.sortedWidgets)
}
})
}
} else {
widget.setMenuBarItem(state: state)
}
}
widget.sizeCallback = { [weak self] in
self?.recalculateWidth()
}
self.widgets.append(widget)
}
public func enable() {
if self.oneView && !self.combinedModules {
self.setupMenuBarItem(true)
}
self.active = true
self.widgets.forEach{ $0.enable() }
self.callback?()
}
public func disable() {
self.widgets.forEach{ $0.disable() }
self.active = false
if self.oneView {
self.setupMenuBarItem(false)
}
self.callback?()
}
private func setupMenuBarItem(_ state: Bool) {
DispatchQueue.main.async(execute: {
if state && self.active {
restoreNSStatusItemPosition(id: self.moduleName)
self.menuBarItem = NSStatusBar.system.statusItem(withLength: 0)
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()
} else if let item = self.menuBarItem {
saveNSStatusItemPosition(id: self.moduleName)
NSStatusBar.system.removeStatusItem(item)
self.menuBarItem = nil
}
})
}
private func recalculateWidth() {
guard self.oneView, self.active 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
self.view.setFrameOrigin(NSPoint(x: 0, y: 0))
self.view.setFrameSize(NSSize(width: w, height: Constants.Widget.height))
self.view.recalculate(self.sortedWidgets)
self.callback?()
}
@objc private func togglePopup() {
if let item = self.menuBarItem, let window = item.button?.window {
NotificationCenter.default.post(name: .togglePopup, object: nil, userInfo: [
"module": self.moduleName,
"origin": window.frame.origin,
"center": window.frame.width/2
])
}
}
@objc private func listenForOneView(_ notification: Notification) {
if notification.userInfo?["module"] as? String == nil {
self.toggleOneView()
} else if let name = notification.userInfo?["module"] as? String, name == self.moduleName, self.active {
self.toggleOneView()
}
}
private func toggleOneView() {
self.activeWidgets.forEach { (w: SWidget) in
w.disable()
}
if self.combinedModules {
self.oneView = true
self.setupMenuBarItem(false)
} else if self.active {
self.oneView = Store.shared.bool(key: "\(self.moduleName)_oneView", defaultValue: self.oneView)
self.setupMenuBarItem(self.oneView)
}
self.activeWidgets.forEach { (w: SWidget) in
w.enable()
}
}
@objc private func listenForWidgetRearrange(_ notification: Notification) {
guard let name = notification.userInfo?["module"] as? String, name == self.moduleName else {
return
}
self.view.recalculate(self.sortedWidgets)
}
}
public class MenuBarView: NSView {
init() {
super.init(frame: NSRect.zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func addWidget(_ view: NSView) {
self.addSubview(view)
}
public func removeWidget(type: widget_t) {
if let view = self.subviews.first(where: { $0.identifier == NSUserInterfaceItemIdentifier(type.rawValue) }) {
view.removeFromSuperview()
}
}
public func recalculate(_ list: [widget_t] = []) {
var x: CGFloat = Constants.Widget.spacing
list.forEach { (type: widget_t) in
if let view = self.subviews.first(where: { $0.identifier == NSUserInterfaceItemIdentifier(type.rawValue) }) {
view.setFrameOrigin(NSPoint(x: x, y: view.frame.origin.y))
x = view.frame.origin.x + view.frame.width + Constants.Widget.spacing
}
}
}
}