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

746 lines
27 KiB
Swift

//
// settings.swift
// Kit
//
// Created by Serhiy Mytrovtsiy on 13/04/2020.
// Using Swift 5.0.
// Running on macOS 10.15.
//
// Copyright © 2020 Serhiy Mytrovtsiy. All rights reserved.
//
import Cocoa
public protocol Settings_p: NSView {
func setState(_ newState: Bool)
}
public protocol Settings_v: NSView {
func load(widgets: [widget_t])
}
open class Settings: NSStackView, Settings_p {
private var config: UnsafePointer<module_c>
private var widgets: [SWidget]
private var segmentedControl: NSSegmentedControl?
private var tabView: NSTabView?
private var moduleSettings: Settings_v?
private var popupSettings: Popup_p?
private var notificationsSettings: NotificationsWrapper?
private var moduleSettingsContainer: NSStackView?
private var widgetSettingsContainer: NSStackView?
private var popupSettingsContainer: NSStackView?
private var notificationsSettingsContainer: NSStackView?
private var enableControl: NSControl?
private var oneViewBtn: NSSwitch?
private let noWidgetsView: EmptyView = EmptyView(msg: localizedString("No available widgets to configure"))
private let noPopupSettingsView: EmptyView = EmptyView(msg: localizedString("No options to configure for the popup in this module"))
private let noNotificationsView: EmptyView = EmptyView(msg: localizedString("No notifications available in this module"))
private var globalOneView: Bool {
Store.shared.bool(key: "OneView", defaultValue: false)
}
private var oneViewState: Bool {
get { Store.shared.bool(key: "\(self.config.pointee.name)_oneView", defaultValue: false) }
set { Store.shared.set(key: "\(self.config.pointee.name)_oneView", value: newValue) }
}
private var isPopupSettingsAvailable: Bool
private var isNotificationsSettingsAvailable: Bool
private var previewView: NSView? = nil
private var settingsView: NSView? = nil
init(config: UnsafePointer<module_c>, widgets: UnsafeMutablePointer<[SWidget]>, moduleSettings: Settings_v?, popupSettings: Popup_p?, notificationsSettings: NotificationsWrapper?) {
self.config = config
self.widgets = widgets.pointee
self.moduleSettings = moduleSettings
self.popupSettings = popupSettings
self.notificationsSettings = notificationsSettings
self.isPopupSettingsAvailable = config.pointee.settingsConfig["popup"] as? Bool ?? false
self.isNotificationsSettingsAvailable = config.pointee.settingsConfig["notifications"] as? Bool ?? false
super.init(frame: NSRect.zero)
self.orientation = .vertical
self.alignment = .width
self.distribution = .fill
self.spacing = Constants.Settings.margin
self.edgeInsets = NSEdgeInsets(
top: Constants.Settings.margin,
left: Constants.Settings.margin,
bottom: Constants.Settings.margin,
right: Constants.Settings.margin
)
let header = self.header()
let settingsView = self.settings()
self.settingsView = settingsView
let previewView = self.preview()
self.previewView = previewView
self.addArrangedSubview(header)
self.addArrangedSubview(settingsView)
self.addArrangedSubview(previewView)
NotificationCenter.default.addObserver(self, selector: #selector(listenForOneView), name: .toggleOneView, object: nil)
self.segmentedControl?.widthAnchor.constraint(equalTo: self.widthAnchor, constant: -(Constants.Settings.margin*2)).isActive = true
}
deinit {
NotificationCenter.default.removeObserver(self, name: .toggleOneView, object: nil)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func setState(_ newState: Bool) {
toggleNSControlState(self.enableControl, state: newState ? .on : .off)
}
private func header() -> NSView {
let view = NSStackView()
view.orientation = .horizontal
view.spacing = Constants.Settings.margin
let widgetSelector = WidgetSelectorView(module: self.config.pointee.name, widgets: self.widgets, stateCallback: self.loadWidget)
// let button = ButtonSelectorView { [weak self] in
// self?.toggleView()
// }
view.addArrangedSubview(widgetSelector)
// view.addArrangedSubview(button)
return view
}
private func preview() -> NSView {
let view = NSStackView()
view.isHidden = true
view.orientation = .vertical
view.addArrangedSubview(EmptyView(height: 0, msg: localizedString("Preview is not available for that module")))
return view
}
private func settings() -> NSView {
let view = NSStackView()
view.orientation = .vertical
view.spacing = Constants.Settings.margin
var labels: [String] = [
localizedString("Module"),
localizedString("Widgets")
]
if self.isPopupSettingsAvailable {
labels.append(localizedString("Popup"))
}
if self.isNotificationsSettingsAvailable {
labels.append(localizedString("Notifications"))
}
let segmentedControl = NSSegmentedControl(labels: labels, trackingMode: .selectOne, target: self, action: #selector(self.switchTabs))
segmentedControl.segmentDistribution = .fillEqually
segmentedControl.selectSegment(withTag: 0)
self.segmentedControl = segmentedControl
let tabView = NSTabView()
tabView.tabViewType = .noTabsNoBorder
tabView.tabViewBorderType = .none
tabView.drawsBackground = false
self.tabView = tabView
let moduleTab: NSTabViewItem = NSTabViewItem()
moduleTab.label = localizedString("Module")
moduleTab.view = {
let container = NSStackView()
container.translatesAutoresizingMaskIntoConstraints = false
let scrollView = ScrollableStackView()
self.moduleSettingsContainer = scrollView.stackView
self.loadModuleSettings()
container.addArrangedSubview(scrollView)
return container
}()
tabView.addTabViewItem(moduleTab)
let widgetTab: NSTabViewItem = NSTabViewItem()
widgetTab.label = localizedString("Widgets")
widgetTab.view = {
let view = ScrollableStackView(frame: tabView.frame)
view.stackView.spacing = 0
self.widgetSettingsContainer = view.stackView
self.loadWidgetSettings()
return view
}()
tabView.addTabViewItem(widgetTab)
if self.isPopupSettingsAvailable {
let popupTab: NSTabViewItem = NSTabViewItem()
popupTab.label = localizedString("Popup")
popupTab.view = {
let view = ScrollableStackView(frame: tabView.frame)
view.stackView.spacing = 0
self.popupSettingsContainer = view.stackView
self.loadPopupSettings()
return view
}()
tabView.addTabViewItem(popupTab)
}
if self.isNotificationsSettingsAvailable {
let notificationsTab: NSTabViewItem = NSTabViewItem()
notificationsTab.label = localizedString("Notifications")
notificationsTab.view = {
let view = ScrollableStackView(frame: tabView.frame)
view.stackView.spacing = 0
self.notificationsSettingsContainer = view.stackView
self.loadNotificationsSettings()
return view
}()
tabView.addTabViewItem(notificationsTab)
}
view.addArrangedSubview(segmentedControl)
view.addArrangedSubview(tabView)
return view
}
private func loadWidget() {
self.loadModuleSettings()
self.loadWidgetSettings()
}
private func loadModuleSettings() {
self.moduleSettingsContainer?.subviews.forEach{ $0.removeFromSuperview() }
if let settingsView = self.moduleSettings {
settingsView.load(widgets: self.widgets.filter{ $0.isActive }.map{ $0.type })
self.moduleSettingsContainer?.addArrangedSubview(settingsView)
} else {
self.moduleSettingsContainer?.addArrangedSubview(NSView())
}
}
private func loadWidgetSettings() {
self.widgetSettingsContainer?.subviews.forEach{ $0.removeFromSuperview() }
let list = self.widgets.filter({ $0.isActive && $0.type != .label })
guard !list.isEmpty else {
self.widgetSettingsContainer?.addArrangedSubview(self.noWidgetsView)
return
}
if self.widgets.filter({ $0.isActive }).count > 1 {
let btn = switchView(
action: #selector(self.toggleOneView),
state: self.oneViewState
)
self.oneViewBtn = btn
self.widgetSettingsContainer?.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Merge widgets"), component: btn)
]))
}
for i in 0...list.count - 1 {
self.widgetSettingsContainer?.addArrangedSubview(WidgetSettings(
title: list[i].type.name(),
image: list[i].image,
settingsView: list[i].item.settings()
))
}
}
private func loadPopupSettings() {
self.popupSettingsContainer?.subviews.forEach{ $0.removeFromSuperview() }
if let settingsView = self.popupSettings, let view = settingsView.settings() {
self.popupSettingsContainer?.addArrangedSubview(view)
} else {
self.popupSettingsContainer?.addArrangedSubview(self.noPopupSettingsView)
}
}
private func loadNotificationsSettings() {
self.notificationsSettingsContainer?.subviews.forEach{ $0.removeFromSuperview() }
if let notificationsView = self.notificationsSettings {
self.notificationsSettingsContainer?.addArrangedSubview(notificationsView)
} else {
self.notificationsSettingsContainer?.addArrangedSubview(self.noNotificationsView)
}
}
@objc func switchTabs(sender: NSSegmentedControl) {
self.tabView?.selectTabViewItem(at: sender.selectedSegment)
}
@objc private func toggleOneView(_ sender: NSControl) {
guard !self.globalOneView else { return }
self.oneViewState = controlState(sender)
NotificationCenter.default.post(name: .toggleOneView, object: nil, userInfo: ["module": self.config.pointee.name])
}
@objc private func listenForOneView(_ notification: Notification) {
guard notification.userInfo?["module"] == nil else { return }
self.oneViewBtn?.isEnabled = !self.globalOneView
if !self.globalOneView {
self.oneViewBtn?.state = self.oneViewState ? .on : .off
}
}
@objc private func toggleView() {
guard let preview = self.previewView, let settings = self.settingsView else { return }
preview.isHidden = !preview.isHidden
settings.isHidden = !settings.isHidden
}
}
private class WidgetSelectorView: NSStackView {
private var module: String
private var stateCallback: () -> Void = {}
private var moved: Bool = false
private var background: NSVisualEffectView = {
let view = NSVisualEffectView(frame: NSRect.zero)
view.blendingMode = .withinWindow
view.material = .contentBackground
view.state = .active
view.wantsLayer = true
view.layer?.cornerRadius = 5
return view
}()
fileprivate init(module: String, widgets: [SWidget], stateCallback: @escaping () -> Void) {
self.module = module
self.stateCallback = stateCallback
super.init(frame: NSRect.zero)
self.translatesAutoresizingMaskIntoConstraints = false
self.edgeInsets = NSEdgeInsets(
top: Constants.Settings.margin,
left: Constants.Settings.margin,
bottom: Constants.Settings.margin,
right: Constants.Settings.margin
)
self.spacing = Constants.Settings.margin
var active: [WidgetPreview] = []
var inactive: [WidgetPreview] = []
if !widgets.isEmpty {
for i in 0...widgets.count - 1 {
let widget = widgets[i]
let preview = WidgetPreview(
id: "\(widget.module)_\(widget.type)",
type: widget.type,
image: widget.image,
isActive: widget.isActive, { [weak self] state in
widget.toggle(state)
self?.stateCallback()
})
if widget.isActive {
active.append(preview)
} else {
inactive.append(preview)
}
}
}
active.sort(by: { $0.position < $1.position })
inactive.sort(by: { $0.position < $1.position })
active.forEach { (widget: WidgetPreview) in
self.addArrangedSubview(widget)
}
let separator = NSView()
separator.identifier = NSUserInterfaceItemIdentifier(rawValue: "separator")
separator.wantsLayer = true
separator.layer?.backgroundColor = NSColor(red: 213/255, green: 213/255, blue: 213/255, alpha: 1).cgColor
self.addArrangedSubview(separator)
inactive.forEach { (widget: WidgetPreview) in
self.addArrangedSubview(widget)
}
self.addArrangedSubview(NSView())
self.addSubview(self.background, positioned: .below, relativeTo: .none)
NSLayoutConstraint.activate([
self.heightAnchor.constraint(equalToConstant: Constants.Widget.height + (Constants.Settings.margin*2)),
separator.widthAnchor.constraint(equalToConstant: 1),
separator.heightAnchor.constraint(equalTo: self.heightAnchor, constant: -6)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateLayer() {
self.background.setFrameSize(self.frame.size)
}
override func mouseUp(with event: NSEvent) {
guard !self.moved else { return }
let location = convert(event.locationInWindow, from: nil)
guard let targetIdx = self.views.firstIndex(where: { $0.hitTest(location) != nil }),
let separatorIdx = self.views.firstIndex(where: { $0.identifier?.rawValue == "separator" }),
self.views[targetIdx].identifier != nil, let view = self.views[targetIdx] as? WidgetPreview else {
super.mouseUp(with: event)
return
}
let newIdx = separatorIdx
view.removeFromSuperviewWithoutNeedingDisplay()
self.insertArrangedSubview(view, at: newIdx)
self.layoutSubtreeIfNeeded()
for (i, v) in self.views(in: .leading).compactMap({$0 as? WidgetPreview}).enumerated() {
v.position = i
}
view.status(separatorIdx < targetIdx)
NotificationCenter.default.post(name: .widgetRearrange, object: nil, userInfo: ["module": self.module])
}
override func mouseDown(with event: NSEvent) {
self.moved = false
let location = convert(event.locationInWindow, from: nil)
guard let targetIdx = self.views.firstIndex(where: { $0.hitTest(location) != nil }),
let separatorIdx = self.views.firstIndex(where: { $0.identifier?.rawValue == "separator" }),
let window = self.window, self.views[targetIdx].identifier != nil else {
super.mouseDragged(with: event)
return
}
let view = self.views[targetIdx]
let copy = ViewCopy(view)
copy.zPosition = 2
copy.transform = CATransform3DMakeScale(0.9, 0.9, 1)
// hide the original view, show the copy
view.subviews.forEach({ $0.isHidden = true })
self.layer?.addSublayer(copy)
// hide the copy view, show the original
defer {
copy.removeFromSuperlayer()
view.subviews.forEach({ $0.isHidden = false })
}
var newIdx = -1
let originCenter = view.frame.midX
let originX = view.frame.origin.x
let p0 = convert(event.locationInWindow, from: nil).x
window.trackEvents(matching: [.leftMouseDragged, .leftMouseUp], timeout: 1e6, mode: .eventTracking) { event, stop in
guard let event = event else {
stop.pointee = true
return
}
if event.type == .leftMouseDragged {
let p1 = self.convert(event.locationInWindow, from: nil).x
let diff = p1 - p0
CATransaction.begin()
CATransaction.setDisableActions(true)
copy.frame.origin.x = originX + diff
CATransaction.commit()
let reordered = self.views.map{
(view: $0, x: $0 !== view ? $0.frame.midX : originCenter + diff)
}.sorted{ $0.x < $1.x }.map { $0.view }
guard let nextIndex = reordered.firstIndex(of: view),
let prevIndex = self.views.firstIndex(of: view) else {
stop.pointee = true
return
}
if nextIndex != prevIndex && nextIndex != self.views.count - 1 {
newIdx = nextIndex
view.removeFromSuperviewWithoutNeedingDisplay()
self.insertArrangedSubview(view, at: newIdx)
self.layoutSubtreeIfNeeded()
for (i, v) in self.views(in: .leading).compactMap({$0 as? WidgetPreview}).enumerated() {
v.position = i
}
}
self.moved = abs(diff) > 1
} else {
if newIdx != -1, let view = self.views[newIdx] as? WidgetPreview {
if newIdx <= separatorIdx && newIdx < targetIdx {
view.status(true)
} else if newIdx >= separatorIdx {
view.status(false)
}
NotificationCenter.default.post(name: .widgetRearrange, object: nil, userInfo: ["module": self.module])
}
view.mouseUp(with: event)
stop.pointee = true
self.moved = true
}
}
}
}
private class WidgetPreview: NSStackView {
private var stateCallback: (_ status: Bool) -> Void = {_ in }
private let rgbImage: NSImage
private let grayImage: NSImage
private let imageView: NSImageView
private var state: Bool
private let id: String
fileprivate var position: Int {
get { Store.shared.int(key: "\(self.id)_position", defaultValue: 0) }
set { Store.shared.set(key: "\(self.id)_position", value: newValue) }
}
fileprivate init(id: String, type: widget_t, image: NSImage, isActive: Bool, _ callback: @escaping (_ status: Bool) -> Void) {
self.id = id
self.stateCallback = callback
self.rgbImage = image
self.grayImage = grayscaleImage(image) ?? image
self.imageView = NSImageView(frame: NSRect(origin: .zero, size: image.size))
self.state = isActive
super.init(frame: NSRect(x: 0, y: 0, width: 0, height: Constants.Widget.height))
self.wantsLayer = true
self.layer?.cornerRadius = 2
self.layer?.borderColor = NSColor(red: 221/255, green: 221/255, blue: 221/255, alpha: 1).cgColor
self.layer?.borderWidth = 1
self.layer?.backgroundColor = NSColor.white.cgColor
self.identifier = NSUserInterfaceItemIdentifier(rawValue: type.rawValue)
self.setAccessibilityElement(true)
self.toolTip = type.name()
self.orientation = .vertical
self.distribution = .fill
self.alignment = .centerY
self.spacing = 0
self.imageView.image = isActive ? self.rgbImage : self.grayImage
self.imageView.alphaValue = isActive ? 1 : 0.75
self.addArrangedSubview(self.imageView)
self.addTrackingArea(NSTrackingArea(
rect: NSRect(
x: Constants.Widget.spacing,
y: 0,
width: self.imageView.frame.width + Constants.Widget.spacing*2,
height: self.frame.height
),
options: [NSTrackingArea.Options.activeAlways, NSTrackingArea.Options.mouseEnteredAndExited, NSTrackingArea.Options.activeInActiveApp],
owner: self,
userInfo: nil
))
NSLayoutConstraint.activate([
self.widthAnchor.constraint(equalToConstant: self.imageView.frame.width + Constants.Widget.spacing*2),
self.heightAnchor.constraint(equalToConstant: self.frame.height)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate func status(_ newState: Bool) {
self.state = newState
self.stateCallback(newState)
self.imageView.image = newState ? self.rgbImage : self.grayImage
self.imageView.alphaValue = newState ? 1 : 0.8
}
override func mouseEntered(with: NSEvent) {
NSCursor.pointingHand.set()
if !self.state {
self.imageView.image = self.rgbImage
self.imageView.alphaValue = 0.9
}
}
override func mouseExited(with: NSEvent) {
NSCursor.arrow.set()
if !self.state {
self.imageView.image = self.grayImage
self.imageView.alphaValue = 0.8
}
}
}
private class WidgetSettings: NSStackView {
fileprivate init(title: String, image: NSImage, settingsView: NSView) {
super.init(frame: NSRect.zero)
self.translatesAutoresizingMaskIntoConstraints = false
self.orientation = .vertical
self.spacing = 0
self.addArrangedSubview(self.header(title, image))
self.addArrangedSubview(settingsView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func header(_ title: String, _ image: NSImage) -> NSView {
let container = NSStackView()
container.translatesAutoresizingMaskIntoConstraints = false
container.orientation = .horizontal
container.edgeInsets = NSEdgeInsets(
top: 6,
left: 0,
bottom: 6,
right: 0
)
container.spacing = 0
container.distribution = .equalCentering
let content = NSStackView()
content.translatesAutoresizingMaskIntoConstraints = false
content.orientation = .vertical
content.distribution = .fill
content.spacing = 0
let title: NSTextField = LabelField(frame: NSRect(x: 0, y: 0, width: 0, height: 0), title)
title.font = NSFont.systemFont(ofSize: 13, weight: .regular)
title.textColor = .textColor
let imageContainer = NSStackView()
imageContainer.orientation = .vertical
imageContainer.spacing = 0
imageContainer.wantsLayer = true
imageContainer.layer?.backgroundColor = NSColor.white.cgColor
imageContainer.layer?.cornerRadius = 2
imageContainer.edgeInsets = NSEdgeInsets(
top: 2,
left: 2,
bottom: 2,
right: 2
)
let imageView = NSImageView(frame: NSRect(origin: .zero, size: image.size))
imageView.image = image
imageContainer.addArrangedSubview(imageView)
content.addArrangedSubview(imageContainer)
content.addArrangedSubview(title)
container.addArrangedSubview(NSView())
container.addArrangedSubview(content)
container.addArrangedSubview(NSView())
return container
}
}
private class ButtonSelectorView: NSStackView {
private var callback: () -> Void
private var background: NSVisualEffectView = {
let view = NSVisualEffectView(frame: NSRect.zero)
view.blendingMode = .withinWindow
view.material = .contentBackground
view.state = .active
view.wantsLayer = true
view.layer?.cornerRadius = 5
return view
}()
private var settingsIcon: NSImage {
if #available(macOS 12.0, *), let icon = iconFromSymbol(name: "gear", scale: .large) {
return icon
}
return NSImage(named: NSImage.Name("settings"))!
}
private var previewIcon: NSImage {
if #available(macOS 12.0, *), let icon = iconFromSymbol(name: "command", scale: .large) {
return icon
}
return NSImage(named: NSImage.Name("chart"))!
}
private var button: NSButton? = nil
private var isSettingsEnabled: Bool = false
fileprivate init(callback: @escaping () -> Void) {
self.callback = callback
super.init(frame: NSRect.zero)
self.heightAnchor.constraint(equalToConstant: Constants.Widget.height + (Constants.Settings.margin*2)).isActive = true
self.translatesAutoresizingMaskIntoConstraints = false
self.edgeInsets = NSEdgeInsets(
top: Constants.Settings.margin,
left: Constants.Settings.margin,
bottom: Constants.Settings.margin,
right: Constants.Settings.margin
)
self.spacing = Constants.Settings.margin
self.addSubview(self.background, positioned: .below, relativeTo: .none)
let button = NSButton()
button.toolTip = localizedString("Open module settings")
button.bezelStyle = .regularSquare
button.translatesAutoresizingMaskIntoConstraints = false
button.imageScaling = .scaleNone
button.image = self.settingsIcon
button.contentTintColor = .secondaryLabelColor
button.isBordered = false
button.action = #selector(self.action)
button.target = self
button.focusRingType = .none
button.widthAnchor.constraint(equalToConstant: Constants.Widget.height).isActive = true
self.button = button
self.addArrangedSubview(button)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateLayer() {
self.background.setFrameSize(self.frame.size)
}
@objc private func action() {
guard let button = self.button else { return }
self.callback()
self.isSettingsEnabled = !self.isSettingsEnabled
if self.isSettingsEnabled {
button.image = self.previewIcon
button.toolTip = localizedString("Close module settings")
} else {
button.image = self.settingsIcon
button.toolTip = localizedString("Open module settings")
}
}
}