feat: added feature to set keyboard shortcut to open/close popup window (#1976)

This commit is contained in:
Serhiy Mytrovtsiy
2025-01-20 17:04:55 +01:00
parent b4c835e97d
commit 58ad6c568b
25 changed files with 346 additions and 39 deletions

View File

@@ -10,6 +10,7 @@
//
import Cocoa
import Carbon
extension String: @retroactive LocalizedError {
public var errorDescription: String? { return self }
@@ -312,9 +313,9 @@ public extension NSView {
return s
}
func buttonIconView(_ action: Selector, icon: NSImage) -> NSButton {
func buttonIconView(_ action: Selector, icon: NSImage, height: CGFloat = 22) -> NSButton {
let button = NSButton()
button.heightAnchor.constraint(equalToConstant: 22).isActive = true
button.heightAnchor.constraint(equalToConstant: height).isActive = true
button.bezelStyle = .regularSquare
button.translatesAutoresizingMaskIntoConstraints = false
button.imageScaling = .scaleNone
@@ -564,3 +565,150 @@ extension CGFloat {
return ceil(self / 10) * 10
}
}
public class KeyboardShartcutView: NSStackView {
private let callback: (_ value: [UInt16]) -> Void
private var startIcon: NSImage {
if #available(macOS 12.0, *), let icon = iconFromSymbol(name: "record.circle", scale: .large) {
return icon
}
return NSImage(named: NSImage.Name("record"))!
}
private var stopIcon: NSImage {
if #available(macOS 12.0, *), let icon = iconFromSymbol(name: "stop.circle.fill", scale: .large) {
return icon
}
return NSImage(named: NSImage.Name("stop"))!
}
private var valueField: NSTextField? = nil
private var startButton: NSButton? = nil
private var stopButton: NSButton? = nil
private var recording: Bool = false
private var keyCodes: [UInt16] = []
private var value: [UInt16] = []
private var interaction: Bool = false
public init(callback: @escaping (_ value: [UInt16]) -> Void, value: [UInt16]) {
self.callback = callback
self.value = value
super.init(frame: NSRect.zero)
self.orientation = .horizontal
let stringValue = value.isEmpty ? localizedString("Disabled") : self.parseValue(value)
let valueField: NSTextField = LabelField(stringValue)
valueField.font = NSFont.systemFont(ofSize: 13, weight: .regular)
valueField.textColor = .textColor
valueField.alignment = .center
let startButton = buttonIconView(#selector(self.startListening), icon: self.startIcon, height: 15)
let stopButton = buttonIconView(#selector(self.stopListening), icon: self.stopIcon, height: 15)
self.addArrangedSubview(valueField)
self.addArrangedSubview(startButton)
self.valueField = valueField
self.startButton = startButton
self.stopButton = stopButton
NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in
self?.handleKeyEvent(event)
return event
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func startListening() {
guard AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary) else { return }
if let btn = self.stopButton {
self.startButton?.removeFromSuperview()
self.addArrangedSubview(btn)
}
self.valueField?.stringValue = localizedString("Listening...")
self.keyCodes = []
self.recording = true
}
@objc private func stopListening() {
if let btn = self.startButton {
self.stopButton?.removeFromSuperview()
self.addArrangedSubview(btn)
}
if self.keyCodes.isEmpty && !self.interaction {
self.value = []
self.valueField?.stringValue = localizedString("Disabled")
}
self.recording = false
self.interaction = false
self.callback(self.value)
}
private func handleKeyEvent(_ event: NSEvent) {
guard self.recording else { return }
self.interaction = true
if event.type == .flagsChanged {
self.keyCodes = []
if event.modifierFlags.contains(.control) { self.keyCodes.append(59) }
if event.modifierFlags.contains(.shift) { self.keyCodes.append(60) }
if event.modifierFlags.contains(.command) { self.keyCodes.append(55) }
if event.modifierFlags.contains(.option) { self.keyCodes.append(58) }
} else if event.type == .keyDown {
self.keyCodes.append(event.keyCode)
self.value = self.keyCodes
}
let list = self.keyCodes.isEmpty ? self.value : self.keyCodes
self.valueField?.stringValue = self.parseValue(list)
}
private func parseValue(_ list: [UInt16]) -> String {
return list.compactMap { self.keyName(virtualKeyCode: $0) }.joined(separator: " + ")
}
private func keyName(virtualKeyCode: UInt16) -> String? {
if virtualKeyCode == 59 {
return "Control"
} else if virtualKeyCode == 60 {
return "Shift"
} else if virtualKeyCode == 55 {
return "Command"
} else if virtualKeyCode == 58 {
return "Option"
}
let maxNameLength = 4
var nameBuffer = [UniChar](repeating: 0, count: maxNameLength)
var nameLength = 0
let modifierKeys = UInt32(alphaLock >> 8) & 0xFF // Caps Lock
var deadKeys: UInt32 = 0
let keyboardType = UInt32(LMGetKbdType())
let source = TISCopyCurrentKeyboardLayoutInputSource().takeRetainedValue()
guard let ptr = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
NSLog("Could not get keyboard layout data")
return nil
}
let layoutData = Unmanaged<CFData>.fromOpaque(ptr).takeUnretainedValue() as Data
let osStatus = layoutData.withUnsafeBytes {
UCKeyTranslate($0.bindMemory(to: UCKeyboardLayout.self).baseAddress, virtualKeyCode, UInt16(kUCKeyActionDown),
modifierKeys, keyboardType, UInt32(kUCKeyTranslateNoDeadKeysMask),
&deadKeys, maxNameLength, &nameLength, &nameBuffer)
}
guard osStatus == noErr else {
NSLog("Code: 0x%04X Status: %+i", virtualKeyCode, osStatus)
return nil
}
return String(utf16CodeUnits: nameBuffer, count: nameLength)
}
}

View File

@@ -78,10 +78,14 @@ open class Module {
}
public var combinedPosition: Int {
get { Store.shared.int(key: "\(self.name)_position", defaultValue: 0) }
set { Store.shared.set(key: "\(self.name)_position", value: newValue) }
set { Store.shared.set(key: "\(self.name)_position", value: newValue) }
}
public var userDefaults: UserDefaults? = UserDefaults(suiteName: "\(Bundle.main.object(forInfoDictionaryKey: "TeamId") as! String).eu.exelban.Stats.widgets")
public var popupKeyboardShortcut: [UInt16] {
return self.popupView?.keyboardShortcut ?? []
}
private var moduleType: ModuleType
private var settingsView: Settings_v? = nil

View File

@@ -12,18 +12,40 @@
import Cocoa
public protocol Popup_p: NSView {
var keyboardShortcut: [UInt16] { get }
var sizeCallback: ((NSSize) -> Void)? { get set }
func settings() -> NSView?
func appear()
func disappear()
func setKeyboardShortcut(_ binding: [UInt16])
}
open class PopupWrapper: NSStackView, Popup_p {
public var title: String
public var keyboardShortcut: [UInt16] = []
open var sizeCallback: ((NSSize) -> Void)? = nil
public init(_ typ: ModuleType, frame: NSRect) {
self.title = typ.rawValue
self.keyboardShortcut = Store.shared.array(key: "\(typ.rawValue)_popup_keyboardShortcut", defaultValue: []) as? [UInt16] ?? []
super.init(frame: frame)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
open func settings() -> NSView? { return nil }
open func appear() {}
open func disappear() {}
open func setKeyboardShortcut(_ binding: [UInt16]) {
self.keyboardShortcut = binding
Store.shared.set(key: "\(self.title)_popup_keyboardShortcut", value: binding)
}
}
public class PopupWindow: NSWindow, NSWindowDelegate {

View File

@@ -37,6 +37,10 @@ public class Store {
return (!self.exist(key: key) ? value : defaults.integer(forKey: key))
}
public func array(key: String, defaultValue value: [Any]) -> [Any] {
return (!self.exist(key: key) ? value : defaults.array(forKey: key)!)
}
public func data(key: String) -> Data? {
return defaults.data(forKey: key)
}
@@ -57,6 +61,10 @@ public class Store {
self.defaults.set(value, forKey: key)
}
public func set(key: String, value: [Any]) {
self.defaults.set(value, forKey: key)
}
public func reset() {
self.defaults.dictionaryRepresentation().keys.forEach { key in
self.defaults.removeObject(forKey: key)

View File

@@ -13,8 +13,6 @@ import Cocoa
import Kit
internal class Popup: PopupWrapper {
private var title: String
private var grid: NSGridView? = nil
private let dashboardHeight: CGFloat = 90
@@ -65,9 +63,7 @@ internal class Popup: PopupWrapper {
}
public init(_ module: ModuleType) {
self.title = module.rawValue
super.init(frame: NSRect(
super.init(module, frame: NSRect(
x: 0,
y: 0,
width: Constants.Popup.width,
@@ -337,6 +333,13 @@ internal class Popup: PopupWrapper {
public override func settings() -> NSView? {
let view = SettingsContainerView()
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Keyboard shortcut"), component: KeyboardShartcutView(
callback: self.setKeyboardShortcut,
value: self.keyboardShortcut
))
]))
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Colorize battery"), component: switchView(
action: #selector(self.toggleColor),

View File

@@ -16,7 +16,7 @@ internal class Popup: PopupWrapper {
private let emptyView: EmptyView = EmptyView(height: 30, isHidden: false, msg: localizedString("No Bluetooth devices are available"))
public init() {
super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 30))
super.init(ModuleType.bluetooth, frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 30))
self.orientation = .vertical
self.spacing = Constants.Popup.margins

View File

@@ -13,8 +13,6 @@ import Cocoa
import Kit
internal class Popup: PopupWrapper {
private var title: String
private let dashboardHeight: CGFloat = 90
private let chartHeight: CGFloat = 120 + Constants.Popup.separatorHeight
private var detailsHeight: CGFloat {
@@ -125,14 +123,10 @@ internal class Popup: PopupWrapper {
}
public init(_ module: ModuleType) {
self.title = module.rawValue
super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
super.init(module, frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
self.spacing = 0
self.orientation = .vertical
// self.setAccessibilityElement(true)
// self.toolTip = self.title
self.systemColorState = SColor.fromString(Store.shared.string(key: "\(self.title)_systemColor", defaultValue: self.systemColorState.key))
self.userColorState = SColor.fromString(Store.shared.string(key: "\(self.title)_userColor", defaultValue: self.userColorState.key))
@@ -546,6 +540,13 @@ internal class Popup: PopupWrapper {
public override func settings() -> NSView? {
let view = SettingsContainerView()
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Keyboard shortcut"), component: KeyboardShartcutView(
callback: self.setKeyboardShortcut,
value: self.keyboardShortcut
))
]))
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("System color"), component: selectView(
action: #selector(self.toggleSystemColor),

View File

@@ -13,8 +13,6 @@ import Cocoa
import Kit
internal class Popup: PopupWrapper {
private var title: String
private let orderTableView: OrderTableView = OrderTableView()
private var list: [Clock_t] = []
@@ -22,9 +20,7 @@ internal class Popup: PopupWrapper {
private var calendarState: Bool = true
public init(_ module: ModuleType) {
self.title = module.rawValue
super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
super.init(module, frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
self.orientation = .vertical
self.spacing = Constants.Popup.margins
@@ -87,6 +83,13 @@ internal class Popup: PopupWrapper {
public override func settings() -> NSView? {
let view = SettingsContainerView()
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Keyboard shortcut"), component: KeyboardShartcutView(
callback: self.setKeyboardShortcut,
value: self.keyboardShortcut
))
]))
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Calendar"), component: switchView(
action: #selector(self.toggleCalendarState),

View File

@@ -13,8 +13,6 @@ import Cocoa
import Kit
internal class Popup: PopupWrapper {
private var title: String
private var readColorState: SColor = .secondBlue
private var readColor: NSColor { self.readColorState.additional as? NSColor ?? NSColor.systemRed }
private var writeColorState: SColor = .secondRed
@@ -43,9 +41,7 @@ internal class Popup: PopupWrapper {
private var lastList: [String] = []
public init(_ module: ModuleType) {
self.title = module.rawValue
super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
super.init(module, frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
self.readColorState = SColor.fromString(Store.shared.string(key: "\(self.title)_readColor", defaultValue: self.readColorState.key))
self.writeColorState = SColor.fromString(Store.shared.string(key: "\(self.title)_writeColor", defaultValue: self.writeColorState.key))
@@ -193,6 +189,13 @@ internal class Popup: PopupWrapper {
public override func settings() -> NSView? {
let view = SettingsContainerView()
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Keyboard shortcut"), component: KeyboardShartcutView(
callback: self.setKeyboardShortcut,
value: self.keyboardShortcut
))
]))
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Write color"), component: selectView(
action: #selector(self.toggleWriteColor),

View File

@@ -76,7 +76,7 @@
<key>Settings</key>
<dict>
<key>popup</key>
<false/>
<true/>
<key>notifications</key>
<true/>
</dict>

View File

@@ -14,7 +14,7 @@ import Kit
internal class Popup: PopupWrapper {
public init() {
super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
super.init(ModuleType.GPU, frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
self.orientation = .vertical
self.spacing = Constants.Popup.margins
@@ -51,6 +51,21 @@ internal class Popup: PopupWrapper {
self.sizeCallback?(self.frame.size)
}
}
// MARK: - Settings
public override func settings() -> NSView? {
let view = SettingsContainerView()
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Keyboard shortcut"), component: KeyboardShartcutView(
callback: self.setKeyboardShortcut,
value: self.keyboardShortcut
))
]))
return view
}
}
private class GPUView: NSStackView {

View File

@@ -13,8 +13,6 @@ import Cocoa
import Kit
internal class Popup: PopupWrapper {
private var title: String
private var uploadContainerView: NSView? = nil
private var uploadView: NSView? = nil
private var uploadValue: Int64 = 0
@@ -105,9 +103,7 @@ internal class Popup: PopupWrapper {
}
public init(_ module: ModuleType) {
self.title = module.rawValue
super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
super.init(module, frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
self.spacing = 0
self.orientation = .vertical
@@ -563,6 +559,13 @@ internal class Popup: PopupWrapper {
public override func settings() -> NSView? {
let view = SettingsContainerView()
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Keyboard shortcut"), component: KeyboardShartcutView(
callback: self.setKeyboardShortcut,
value: self.keyboardShortcut
))
]))
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Color of download"), component: selectView(
action: #selector(self.toggleDownloadColor),

View File

@@ -13,8 +13,6 @@ import Cocoa
import Kit
internal class Popup: PopupWrapper {
private var title: String
private var grid: NSGridView? = nil
private let dashboardHeight: CGFloat = 90
@@ -69,9 +67,7 @@ internal class Popup: PopupWrapper {
private var chartColor: NSColor { self.chartColorState.additional as? NSColor ?? NSColor.systemBlue }
public init(_ module: ModuleType) {
self.title = module.rawValue
super.init(frame: NSRect(
super.init(module, frame: NSRect(
x: 0,
y: 0,
width: Constants.Popup.width,
@@ -289,6 +285,13 @@ internal class Popup: PopupWrapper {
public override func settings() -> NSView? {
let view = SettingsContainerView()
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Keyboard shortcut"), component: KeyboardShartcutView(
callback: self.setKeyboardShortcut,
value: self.keyboardShortcut
))
]))
view.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("App color"), component: selectView(
action: #selector(toggleAppColor),

View File

@@ -46,7 +46,7 @@ internal class Popup: PopupWrapper {
}
public init() {
super.init(frame: NSRect( x: 0, y: 0, width: Constants.Popup.width, height: 0))
super.init(ModuleType.sensors, frame: NSRect( x: 0, y: 0, width: Constants.Popup.width, height: 0))
self.fanValueState = FanValue(rawValue: Store.shared.string(key: "Sensors_popup_fanValue", defaultValue: self.fanValueState.rawValue)) ?? .percentage
@@ -57,6 +57,13 @@ internal class Popup: PopupWrapper {
self.settingsView.orientation = .vertical
self.settingsView.spacing = Constants.Settings.margin
self.settingsView.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Keyboard shortcut"), component: KeyboardShartcutView(
callback: self.setKeyboardShortcut,
value: self.keyboardShortcut
))
]))
self.settingsView.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Fan value"), component: selectView(
action: #selector(self.toggleFanValue),

View File

@@ -74,6 +74,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
self.icon()
NotificationCenter.default.addObserver(self, selector: #selector(listenForAppPause), name: .pause, object: nil)
NSEvent.addGlobalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in
self?.handleKeyEvent(event)
}
NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in
self?.handleKeyEvent(event)
return event
}
info("Stats started in \((startingPoint.timeIntervalSinceNow * -1).rounded(toPlaces: 4)) seconds")
self.startTS = Date()

View File

@@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "baseline_radio_button_checked_black_20pt_1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "baseline_radio_button_checked_black_20pt_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "baseline_radio_button_checked_black_20pt_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

View File

@@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "baseline_stop_circle_black_20pt_1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "baseline_stop_circle_black_20pt_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "baseline_stop_circle_black_20pt_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

View File

@@ -208,9 +208,12 @@ internal class CombinedView: NSObject, NSGestureRecognizerDelegate {
}
private class Popup: NSStackView, Popup_p {
fileprivate var keyboardShortcut: [UInt16] = []
fileprivate var sizeCallback: ((NSSize) -> Void)? = nil
init() {
self.keyboardShortcut = Store.shared.array(key: "CombinedModules_popup_keyboardShortcut", defaultValue: []) as? [UInt16] ?? []
super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0))
self.orientation = .vertical
@@ -234,6 +237,10 @@ private class Popup: NSStackView, Popup_p {
fileprivate func settings() -> NSView? { return nil }
fileprivate func appear() {}
fileprivate func disappear() {}
fileprivate func setKeyboardShortcut(_ binding: [UInt16]) {
self.keyboardShortcut = binding
Store.shared.set(key: "CombinedModules_popup_keyboardShortcut", value: binding)
}
@objc private func reinit() {
self.subviews.forEach({ $0.removeFromSuperview() })

View File

@@ -265,4 +265,25 @@ extension AppDelegate {
@objc internal func openSettings() {
NotificationCenter.default.post(name: .toggleSettings, object: nil, userInfo: ["module": "Dashboard"])
}
internal func handleKeyEvent(_ event: NSEvent) {
var keyCodes: [UInt16] = []
if event.modifierFlags.contains(.control) { keyCodes.append(59) }
if event.modifierFlags.contains(.shift) { keyCodes.append(60) }
if event.modifierFlags.contains(.command) { keyCodes.append(55) }
if event.modifierFlags.contains(.option) { keyCodes.append(58) }
keyCodes.append(event.keyCode)
guard !keyCodes.isEmpty,
let module = modules.first(where: { $0.enabled && $0.popupKeyboardShortcut == keyCodes }),
let widget = module.menuBar.widgets.filter({ $0.isActive }).first,
let window = widget.item.window else { return }
NotificationCenter.default.post(name: .togglePopup, object: nil, userInfo: [
"module": module.name,
"widget": widget.type,
"origin": window.frame.origin,
"center": window.frame.width/2
])
}
}