diff --git a/Clocker/Clocker.xcodeproj/project.pbxproj b/Clocker/Clocker.xcodeproj/project.pbxproj index fba581a..9698970 100755 --- a/Clocker/Clocker.xcodeproj/project.pbxproj +++ b/Clocker/Clocker.xcodeproj/project.pbxproj @@ -7,15 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 3508CC942599FFEC000E3530 /* MenubarHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3508CC932599FFEC000E3530 /* MenubarHandler.swift */; }; + 3508CC9A259A0001000E3530 /* StatusItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3508CC99259A0001000E3530 /* StatusItemView.swift */; }; + 3508CC9F259A000E000E3530 /* StatusItemHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3508CC9E259A000E000E3530 /* StatusItemHandler.swift */; }; + 3508CCAA259A0027000E3530 /* StatusContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3508CCA9259A0027000E3530 /* StatusContainerView.swift */; }; 35190E47255F53F5006E9C85 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C36F3C2259D892002FA5C6 /* Logger.swift */; }; 357391872507277500D30819 /* HourMarkerViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 357391852507277500D30819 /* HourMarkerViewItem.swift */; }; 357391882507277500D30819 /* HourMarkerViewItem.xib in Resources */ = {isa = PBXBuildFile; fileRef = 357391862507277500D30819 /* HourMarkerViewItem.xib */; }; 3595FAD0227F88BC0044A12A /* UserDefaults + KVOExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3595FACF227F88BC0044A12A /* UserDefaults + KVOExtensions.swift */; }; 35C11E2124873A550031F18C /* VersionUpdateHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C11E2024873A550031F18C /* VersionUpdateHandler.swift */; }; - 35C36EE422595EFD002FA5C6 /* StatusContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C36EE022595EFD002FA5C6 /* StatusContainerView.swift */; }; - 35C36EE522595EFD002FA5C6 /* MenubarHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C36EE122595EFD002FA5C6 /* MenubarHandler.swift */; }; - 35C36EE622595EFD002FA5C6 /* StatusItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C36EE222595EFD002FA5C6 /* StatusItemView.swift */; }; - 35C36EE722595EFD002FA5C6 /* StatusItemHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C36EE322595EFD002FA5C6 /* StatusItemHandler.swift */; }; 35C36EF122595F14002FA5C6 /* OnboardingPermissionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C36EE822595F13002FA5C6 /* OnboardingPermissionsViewController.swift */; }; 35C36EF222595F14002FA5C6 /* OnboardingWelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C36EE922595F13002FA5C6 /* OnboardingWelcomeViewController.swift */; }; 35C36EF322595F14002FA5C6 /* WelcomeView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 35C36EEA22595F13002FA5C6 /* WelcomeView.xib */; }; @@ -225,6 +225,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 3508CC932599FFEC000E3530 /* MenubarHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenubarHandler.swift; sourceTree = ""; }; + 3508CC99259A0001000E3530 /* StatusItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusItemView.swift; sourceTree = ""; }; + 3508CC9E259A000E000E3530 /* StatusItemHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusItemHandler.swift; sourceTree = ""; }; + 3508CCA9259A0027000E3530 /* StatusContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContainerView.swift; sourceTree = ""; }; 352AF497232E07B400D96FA7 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/InfoPlist.strings; sourceTree = ""; }; 352AF499232E07B400D96FA7 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Localizable.strings; sourceTree = ""; }; 3545C52A22612BCC00121E25 /* TimezoneDataEqualityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimezoneDataEqualityTests.swift; sourceTree = ""; }; @@ -234,10 +238,6 @@ 357391862507277500D30819 /* HourMarkerViewItem.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HourMarkerViewItem.xib; sourceTree = ""; }; 3595FACF227F88BC0044A12A /* UserDefaults + KVOExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults + KVOExtensions.swift"; sourceTree = ""; }; 35C11E2024873A550031F18C /* VersionUpdateHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionUpdateHandler.swift; sourceTree = ""; }; - 35C36EE022595EFD002FA5C6 /* StatusContainerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusContainerView.swift; sourceTree = ""; }; - 35C36EE122595EFD002FA5C6 /* MenubarHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MenubarHandler.swift; sourceTree = ""; }; - 35C36EE222595EFD002FA5C6 /* StatusItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItemView.swift; sourceTree = ""; }; - 35C36EE322595EFD002FA5C6 /* StatusItemHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItemHandler.swift; sourceTree = ""; }; 35C36EE822595F13002FA5C6 /* OnboardingPermissionsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingPermissionsViewController.swift; sourceTree = ""; }; 35C36EE922595F13002FA5C6 /* OnboardingWelcomeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingWelcomeViewController.swift; sourceTree = ""; }; 35C36EEA22595F13002FA5C6 /* WelcomeView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = WelcomeView.xib; sourceTree = ""; }; @@ -483,10 +483,10 @@ 35C36EDF22595D9C002FA5C6 /* Menu Bar */ = { isa = PBXGroup; children = ( - 35C36EE122595EFD002FA5C6 /* MenubarHandler.swift */, - 35C36EE022595EFD002FA5C6 /* StatusContainerView.swift */, - 35C36EE322595EFD002FA5C6 /* StatusItemHandler.swift */, - 35C36EE222595EFD002FA5C6 /* StatusItemView.swift */, + 3508CC932599FFEC000E3530 /* MenubarHandler.swift */, + 3508CC99259A0001000E3530 /* StatusItemView.swift */, + 3508CC9E259A000E000E3530 /* StatusItemHandler.swift */, + 3508CCA9259A0027000E3530 /* StatusContainerView.swift */, ); path = "Menu Bar"; sourceTree = ""; @@ -1214,6 +1214,7 @@ 9AB6F15D2259D08300A44663 /* iVersion.m in Sources */, 35C36EF622595F14002FA5C6 /* OnboardingController.swift in Sources */, 9AB6F1622259D1B000A44663 /* PreferencesDataSource.swift in Sources */, + 3508CC9A259A0001000E3530 /* StatusItemView.swift in Sources */, 35C36F672259DF4C002FA5C6 /* RateController.swift in Sources */, 35C36F472259D892002FA5C6 /* Reach.swift in Sources */, 35C36EF222595F14002FA5C6 /* OnboardingWelcomeViewController.swift in Sources */, @@ -1224,16 +1225,15 @@ 35C36F412259D892002FA5C6 /* Themer.swift in Sources */, 35C36F452259D892002FA5C6 /* Strings.swift in Sources */, 35C36EF722595F14002FA5C6 /* FinalOnboardingViewController.swift in Sources */, - 35C36EE622595EFD002FA5C6 /* StatusItemView.swift in Sources */, 35C36FA12259ED6D002FA5C6 /* EventCenter.swift in Sources */, 9A5951BD1C1D0A8D009C17AA /* CommonStrings.m in Sources */, 357391872507277500D30819 /* HourMarkerViewItem.swift in Sources */, - 35C36EE422595EFD002FA5C6 /* StatusContainerView.swift in Sources */, 35C36F782259E1D0002FA5C6 /* Foundation + Additions.swift in Sources */, 35C36F16225961DA002FA5C6 /* Date+Inits.swift in Sources */, 35C36F4F2259D981002FA5C6 /* AppDefaults.swift in Sources */, 35C36F942259EB87002FA5C6 /* CLTimezoneData.m in Sources */, 35C36F5D2259DD96002FA5C6 /* TimezoneDataOperations.swift in Sources */, + 3508CC942599FFEC000E3530 /* MenubarHandler.swift in Sources */, 35C36F14225961DA002FA5C6 /* Integer+DateTools.swift in Sources */, 35C36FA22259ED6D002FA5C6 /* RemindersHandler.swift in Sources */, 35C36F622259DE67002FA5C6 /* NotesPopover.swift in Sources */, @@ -1246,7 +1246,6 @@ 35C36F702259E185002FA5C6 /* BackgroundPanelView.swift in Sources */, 9AB6F1582259CFFC00A44663 /* AboutViewController.swift in Sources */, 35C36F2122596253002FA5C6 /* AppearanceViewController.swift in Sources */, - 35C36EE722595EFD002FA5C6 /* StatusItemHandler.swift in Sources */, 35C36F1B225961DA002FA5C6 /* Date+Format.swift in Sources */, 35C36F372259D7C3002FA5C6 /* AddTableViewCell.swift in Sources */, 35C36F12225961DA002FA5C6 /* Date+Components.swift in Sources */, @@ -1255,7 +1254,6 @@ 35C36FA42259EEC2002FA5C6 /* AppDelegate.swift in Sources */, 35C36F5E2259DD96002FA5C6 /* TimezoneData.swift in Sources */, 35C36F19225961DA002FA5C6 /* Enums.swift in Sources */, - 35C36EE522595EFD002FA5C6 /* MenubarHandler.swift in Sources */, 35C36F5A2259DD8A002FA5C6 /* Panelr.swift in Sources */, 35C36F712259E185002FA5C6 /* NoTimezoneView.swift in Sources */, 35C36F2B2259D6FA002FA5C6 /* ParentPanelController.swift in Sources */, @@ -1267,6 +1265,7 @@ 35C36F572259DD8A002FA5C6 /* TimezoneDataSource.swift in Sources */, 35C36F462259D892002FA5C6 /* DataStore.swift in Sources */, 9ACF618D231DABAE00F5E51E /* SearchDataSource.swift in Sources */, + 3508CC9F259A000E000E3530 /* StatusItemHandler.swift in Sources */, C2CCCD8220619C4C00F2DFC2 /* LocationController.swift in Sources */, 35C36F4B2259D971002FA5C6 /* UnderlinedButton.swift in Sources */, 9AB6F1562259CF3900A44663 /* CalendarViewController.swift in Sources */, @@ -1278,6 +1277,7 @@ 9AB6F1672259D23200A44663 /* PermissionsViewController.swift in Sources */, 9AB6F1642259D1B900A44663 /* ParentViewController.swift in Sources */, 35C36F1C225961DA002FA5C6 /* TimePeriodChain.swift in Sources */, + 3508CCAA259A0027000E3530 /* StatusContainerView.swift in Sources */, 35C36F11225961DA002FA5C6 /* TimePeriodGroup.swift in Sources */, 35C36EF922595F14002FA5C6 /* OnboardingParentViewController.swift in Sources */, 35C36F4E2259D981002FA5C6 /* DateFormatterManager.swift in Sources */, diff --git a/Clocker/Preferences/Menu Bar/MenubarHandler.swift b/Clocker/Preferences/Menu Bar/MenubarHandler.swift new file mode 100644 index 0000000..eddee16 --- /dev/null +++ b/Clocker/Preferences/Menu Bar/MenubarHandler.swift @@ -0,0 +1,60 @@ +// Copyright © 2015 Abhishek Banthia + +import Cocoa +import EventKit + +class MenubarHandler: NSObject { + func titleForMenubar() -> String? { + if let nextEvent = checkForUpcomingEvents() { + return nextEvent + } + + guard let menubarTitles = DataStore.shared().menubarTimezones() else { + return nil + } + + // If the menubar is in compact mode, we don't need any of the below calculations; exit early + if DataStore.shared().shouldDisplay(.menubarCompactMode) { + return nil + } + + if menubarTitles.isEmpty == false { + let titles = menubarTitles.map { (data) -> String? in + let timezone = TimezoneData.customObject(from: data) + let operationsObject = TimezoneDataOperations(with: timezone!) + return "\(operationsObject.menuTitle().trimmingCharacters(in: NSCharacterSet.whitespacesAndNewlines))" + } + + let titlesStringified = titles.compactMap { $0 } + return titlesStringified.joined(separator: " ") + } + + return nil + } + + private func checkForUpcomingEvents() -> String? { + if DataStore.shared().shouldDisplay(.showMeetingInMenubar) { + let filteredDates = EventCenter.sharedCenter().eventsForDate + let autoupdatingCal = EventCenter.sharedCenter().autoupdatingCalendar + guard let events = filteredDates[autoupdatingCal.startOfDay(for: Date())] else { + return nil + } + + for event in events { + if event.event.startDate.timeIntervalSinceNow > 0, !event.isAllDay { + let timeForEventToStart = event.event.startDate.timeIntervalSinceNow / 60 + + if timeForEventToStart > 30 { + Logger.info("Our next event: \(event.event.title ?? "Error") starts in \(timeForEventToStart) mins") + + continue + } + + return EventCenter.sharedCenter().format(event: event.event) + } + } + } + + return nil + } +} diff --git a/Clocker/Preferences/Menu Bar/StatusContainerView.swift b/Clocker/Preferences/Menu Bar/StatusContainerView.swift new file mode 100644 index 0000000..8890b50 --- /dev/null +++ b/Clocker/Preferences/Menu Bar/StatusContainerView.swift @@ -0,0 +1,181 @@ +// Copyright © 2015 Abhishek Banthia + +import Cocoa + +func bufferCalculatedWidth() -> Int { + var totalWidth = 55 + + if DataStore.shared().shouldShowDayInMenubar() { + totalWidth += 12 + } + + if DataStore.shared().isBufferRequiredForTwelveHourFormats() { + totalWidth += 20 + } + + if DataStore.shared().shouldShowDateInMenubar() { + totalWidth += 20 + } + + return totalWidth +} + +func compactWidth(for timezone: TimezoneData) -> Int { + var totalWidth = 55 + let timeFormat = timezone.timezoneFormat() + + if DataStore.shared().shouldShowDayInMenubar() { + totalWidth += 12 + } + + if timeFormat == DateFormat.twelveHour + || timeFormat == DateFormat.twelveHourWithSeconds + || timeFormat == DateFormat.twelveHourWithZero + || timeFormat == DateFormat.twelveHourWithSeconds { + totalWidth += 20 + } else if timeFormat == DateFormat.twentyFourHour + || timeFormat == DateFormat.twentyFourHourWithSeconds { + totalWidth += 0 + } + + if timezone.shouldShowSeconds() { + // Slight buffer needed when the Menubar supplementary text was Mon 9:27:58 AM + totalWidth += 15 + } + + if DataStore.shared().shouldShowDateInMenubar() { + totalWidth += 20 + } + + print("-- Compact Width is \(totalWidth)") + return totalWidth +} + +// Test with Sat 12:46 AM +let bufferWidth: CGFloat = 9.5 + +class StatusContainerView: NSView { + private var previousX: Int = 0 + + override func awakeFromNib() { + super.awakeFromNib() + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + init(with timezones: [Data]) { + func addSubviews() { + timezones.forEach { + if let timezoneObject = TimezoneData.customObject(from: $0) { + addTimezone(timezoneObject) + } + } + } + + let timeBasedAttributes = [ + NSAttributedString.Key.font: compactModeTimeFont, + NSAttributedString.Key.backgroundColor: NSColor.clear, + NSAttributedString.Key.paragraphStyle: defaultParagraphStyle, + ] + + func containerWidth(for timezones: [Data]) -> CGFloat { + let compressedWidth = timezones.reduce(0.0) { (result, timezone) -> CGFloat in + + if let timezoneObject = TimezoneData.customObject(from: timezone) { + let precalculatedWidth = Double(compactWidth(for: timezoneObject)) + let operationObject = TimezoneDataOperations(with: timezoneObject) + let calculatedSubtitleSize = compactModeTimeFont.size(operationObject.compactMenuSubtitle(), precalculatedWidth, attributes: timeBasedAttributes) + let calculatedTitleSize = compactModeTimeFont.size(operationObject.compactMenuTitle(), precalculatedWidth, attributes: timeBasedAttributes) + let showSeconds = timezoneObject.shouldShowSeconds() + let secondsBuffer: CGFloat = showSeconds ? 7 : 0 + return result + max(calculatedTitleSize.width, calculatedSubtitleSize.width) + bufferWidth + secondsBuffer + } + + return result + CGFloat(bufferCalculatedWidth()) + } + + let calculatedWidth = min(compressedWidth, + CGFloat(timezones.count * bufferCalculatedWidth())) + return calculatedWidth + } + + let statusItemWidth = containerWidth(for: timezones) + let frame = NSRect(x: 0, y: 0, width: statusItemWidth, height: 30) + super.init(frame: frame) + + addSubviews() + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func addTimezone(_ timezone: TimezoneData) { + let calculatedWidth = bestWidth(for: timezone) + let frame = NSRect(x: previousX, y: 0, width: calculatedWidth, height: 30) + + let statusItemView = StatusItemView(frame: frame) + statusItemView.dataObject = timezone + + addSubview(statusItemView) + + previousX += calculatedWidth + } + + private func bestWidth(for timezone: TimezoneData) -> Int { + var textColor = hasDarkAppearance ? NSColor.white : NSColor.black + + if #available(OSX 11.0, *) { + textColor = NSColor.white + } + + let timeBasedAttributes = [ + NSAttributedString.Key.font: compactModeTimeFont, + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.backgroundColor: NSColor.clear, + NSAttributedString.Key.paragraphStyle: defaultParagraphStyle, + ] + + let operation = TimezoneDataOperations(with: timezone) + let bestSize = compactModeTimeFont.size(operation.compactMenuSubtitle(), + Double(compactWidth(for: timezone)), attributes: timeBasedAttributes) + let bestTitleSize = compactModeTimeFont.size(operation.compactMenuTitle(), Double(compactWidth(for: timezone)), attributes: timeBasedAttributes) + + return Int(max(bestSize.width, bestTitleSize.width) + bufferWidth) + } + + func updateTime() { + if subviews.isEmpty { + assertionFailure("Subviews count should > 0") + } + + // See if frame's width needs any adjustment + adjustWidthIfNeccessary() + } + + private func adjustWidthIfNeccessary() { + var newWidth: CGFloat = 0 + + subviews.forEach { + if let statusItem = $0 as? StatusItemView, statusItem.isHidden == false { + // Determine what's the best width required to display the current string. + let newBestWidth = CGFloat(bestWidth(for: statusItem.dataObject)) + + // Let's note if the current width is too small/correct + newWidth += statusItem.frame.size.width != newBestWidth ? newBestWidth : statusItem.frame.size.width + + statusItem.frame = CGRect(x: statusItem.frame.origin.x, + y: statusItem.frame.origin.y, + width: newBestWidth, + height: statusItem.frame.size.height) + + statusItem.updateTimeInMenubar() + } + } + + if newWidth != frame.size.width, newWidth > frame.size.width + 2.0 { + Logger.info("Correcting our width to \(newWidth) and the previous width was \(frame.size.width)") + frame = CGRect(x: frame.origin.x, y: frame.origin.y, width: newWidth, height: frame.size.height) + } + } +} diff --git a/Clocker/Preferences/Menu Bar/StatusItemHandler.swift b/Clocker/Preferences/Menu Bar/StatusItemHandler.swift new file mode 100644 index 0000000..4d67a06 --- /dev/null +++ b/Clocker/Preferences/Menu Bar/StatusItemHandler.swift @@ -0,0 +1,354 @@ +// Copyright © 2015 Abhishek Banthia + +import Cocoa + +private enum MenubarState { + case compactText + case standardText + case icon +} + +class StatusItemHandler: NSObject { + var hasActiveIcon: Bool = false + + var menubarTimer: Timer? + + var statusItem: NSStatusItem = { + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem.highlightMode = false + return statusItem + }() + + private var menubarTitleHandler = MenubarHandler() + + private var parentView: StatusContainerView? + + private var nsCalendar = Calendar.autoupdatingCurrent + + private lazy var units: Set = Set([.era, .year, .month, .day, .hour, .minute]) + + private var userNotificationsDidChangeNotif: NSObjectProtocol? + + // Current State is set twice when the user first launches an app. + // First, when StatusItemHandler() is instantiated in AppDelegate + // Second, when AppDelegate.fetchLocalTimezone() is called triggering a customLabel didSet. + // TODO: Make sure it's set just once. + private var currentState: MenubarState = .standardText { + didSet { + // Do some cleanup + switch oldValue { + case .compactText: + statusItem.view = nil + parentView = nil + case .standardText: + statusItem.button?.title = CLEmptyString + case .icon: + statusItem.button?.image = nil + } + + // Now setup for the new menubar state + switch currentState { + case .compactText: + setupForCompactTextMode() + case .standardText: + setupForStandardTextMode() + case .icon: + setClockerIcon() + } + + Logger.info("\nStatus Bar Current State changed: \(currentState)\n") + } + } + + override init() { + super.init() + + setupStatusItem() + setupNotificationObservers() + } + + func setupStatusItem() { + // Let's figure out the initial menubar state + var menubarState = MenubarState.icon + + let shouldTextBeDisplayed = DataStore.shared().menubarTimezones()?.isEmpty ?? true + + if !shouldTextBeDisplayed || DataStore.shared().shouldDisplay(.showMeetingInMenubar) { + if DataStore.shared().shouldDisplay(.menubarCompactMode) { + menubarState = .compactText + } else { + menubarState = .standardText + } + } + + // Initial state has been figured out. Time to set it! + currentState = menubarState + + func setSelector() { + if #available(macOS 10.14, *) { + statusItem.button?.action = #selector(menubarIconClicked(_:)) + } else { + statusItem.action = #selector(menubarIconClicked(_:)) + } + } + + statusItem.target = self + statusItem.autosaveName = NSStatusItem.AutosaveName("ClockerStatusItem") + setSelector() + } + + private func setupNotificationObservers() { + let center = NotificationCenter.default + let mainQueue = OperationQueue.main + + center.addObserver(self, + selector: #selector(updateMenubar), + name: NSWorkspace.didWakeNotification, + object: nil) + + DistributedNotificationCenter.default.addObserver(self, selector: #selector(respondToInterfaceStyleChange), + name: .interfaceStyleDidChange, + object: nil) + + userNotificationsDidChangeNotif = center.addObserver(forName: UserDefaults.didChangeNotification, + object: self, + queue: mainQueue) { _ in + self.setupStatusItem() + } + } + + deinit { + if let userNotifsDidChange = userNotificationsDidChangeNotif { + NotificationCenter.default.removeObserver(userNotifsDidChange) + } + } + + private func constructCompactView() { + parentView = nil + + let menubarTimezones = retrieveSyncedMenubarTimezones() + + if menubarTimezones.isEmpty { + currentState = .icon + return + } + + parentView = StatusContainerView(with: menubarTimezones) + statusItem.view = parentView + statusItem.view?.window?.backgroundColor = NSColor.clear + } + + private func retrieveSyncedMenubarTimezones() -> [Data] { + let defaultPreferences = DataStore.shared().retrieve(key: CLDefaultPreferenceKey) as? [Data] ?? [] + + let menubarTimezones = defaultPreferences.filter { (data) -> Bool in + if let timezoneObj = TimezoneData.customObject(from: data) { + return timezoneObj.isFavourite == 1 + } + return false + } + return menubarTimezones + } + + // This is called when the Apple interface style pre-Mojave is changed. + // In High Sierra and before, we could have a dark or light menubar and dock + // Our icon is template, so it changes automatically; so is our standard status bar text + // Only need to handle the compact mode! + @objc func respondToInterfaceStyleChange() { + if DataStore.shared().shouldDisplay(.menubarCompactMode) { + updateCompactMenubar() + } + } + + @objc func setHasActiveIcon(_ value: Bool) { + hasActiveIcon = value + } + + @objc func menubarIconClicked(_ sender: Any) { + guard let mainDelegate = NSApplication.shared.delegate as? AppDelegate else { + return + } + + mainDelegate.togglePanel(sender) + } + + @objc func updateMenubar() { + guard let fireDate = calculateFireDate() else { return } + + let shouldDisplaySeconds = shouldDisplaySecondsInMenubar() + + menubarTimer = Timer(fire: fireDate, + interval: 0, + repeats: false, + block: { [weak self] _ in + + if let strongSelf = self { + strongSelf.performTimerWork() + } + }) + + // Tolerance, even a small amount, has a positive imapct on the power usage. As a rule, we set it to 10% of the interval + menubarTimer?.tolerance = shouldDisplaySeconds ? 0.5 : 20 + + guard let runLoopTimer = menubarTimer else { + Logger.info("Timer is unexpectedly nil") + return + } + + RunLoop.main.add(runLoopTimer, forMode: .common) + } + + private func shouldDisplaySecondsInMenubar() -> Bool { + let syncedTimezones = retrieveSyncedMenubarTimezones() + + for timezone in syncedTimezones { + if let timezoneObj = TimezoneData.customObject(from: timezone) { + let shouldShowSeconds = timezoneObj.shouldShowSeconds() + if shouldShowSeconds { + return true + } + } + continue + } + + return false + } + + private func calculateFireDate() -> Date? { + let shouldDisplaySeconds = shouldDisplaySecondsInMenubar() + let menubarFavourites = DataStore.shared().menubarTimezones() + + if !units.contains(.second), shouldDisplaySeconds { + units.insert(.second) + } + + var components = nsCalendar.dateComponents(units, from: Date()) + + // We want to update every second only when there's a timezone present! + if shouldDisplaySeconds, let seconds = components.second, let favourites = menubarFavourites, !favourites.isEmpty { + components.second = seconds + 1 + } else if let minutes = components.minute { + components.minute = minutes + 1 + } else { + Logger.info("Unable to create date components for the menubar timewr") + return nil + } + + guard let fireDate = nsCalendar.date(from: components) else { + Logger.info("Unable to form Fire Date") + return nil + } + + return fireDate + } + + func updateCompactMenubar() { + parentView?.updateTime() + } + + func performTimerWork() { + if currentState == .compactText { + updateCompactMenubar() + updateMenubar() + } else if currentState == .standardText, let title = menubarTitleHandler.titleForMenubar() { + // Need setting button's image to nil + // Especially if we have showUpcomingEvents turned to true and menubar timezones are empty + statusItem.button?.image = nil + statusItem.button?.title = title + updateMenubar() + } else { + setClockerIcon() + menubarTimer?.invalidate() + } + } + + private func setupForStandardTextMode() { + Logger.info("Initializing menubar timer") + + // Let's invalidate the previous timer + menubarTimer?.invalidate() + menubarTimer = nil + + setupForStandardText() + updateMenubar() + } + + func invalidateTimer(showIcon show: Bool, isSyncing sync: Bool) { + // Check if user is not showing + // 1. Timezones + // 2. Upcoming Event + let menubarFavourites = DataStore.shared().menubarTimezones() ?? [] + + if menubarFavourites.isEmpty, DataStore.shared().shouldDisplay(.showMeetingInMenubar) == false { + Logger.info("Invalidating menubar timer!") + + invalidation() + + if show { + currentState = .icon + } + + } else if sync { + Logger.info("Invalidating menubar timer for sync purposes!") + + invalidation() + + if show { + setClockerIcon() + } + + } else { + Logger.info("Not stopping menubar timer!") + } + } + + private func invalidation() { + menubarTimer?.invalidate() + } + + private func setClockerIcon() { + if statusItem.view != nil { + statusItem.view = nil + } + + if statusItem.button?.image?.name() == NSImage.Name.menubarIcon { + return + } + + statusItem.button?.title = CLEmptyString + statusItem.button?.image = NSImage(named: .menubarIcon) + statusItem.button?.imagePosition = .imageOnly + } + + private func setupForStandardText() { + var menubarText = CLEmptyString + + if let menubarTitle = menubarTitleHandler.titleForMenubar() { + menubarText = menubarTitle + } else if DataStore.shared().shouldDisplay(.showMeetingInMenubar) { + // Don't have any meeting to show + } else { + // We have no favourites to display and no meetings to show. + // That means we should display our icon! + } + + guard !menubarText.isEmpty else { + setClockerIcon() + return + } + + statusItem.button?.title = menubarText + statusItem.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 14.0, weight: NSFont.Weight.regular) + statusItem.button?.image = nil + statusItem.button?.imagePosition = .imageLeft + } + + private func setupForCompactTextMode() { + // Let's invalidate the previous timer + menubarTimer?.invalidate() + menubarTimer = nil + + constructCompactView() + updateMenubar() + } +} diff --git a/Clocker/Preferences/Menu Bar/StatusItemView.swift b/Clocker/Preferences/Menu Bar/StatusItemView.swift new file mode 100644 index 0000000..0b3f5bb --- /dev/null +++ b/Clocker/Preferences/Menu Bar/StatusItemView.swift @@ -0,0 +1,132 @@ +// Copyright © 2015 Abhishek Banthia + +import Cocoa + +var defaultParagraphStyle: NSMutableParagraphStyle { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + paragraphStyle.lineBreakMode = .byTruncatingTail + return paragraphStyle +} + +var compactModeTimeFont: NSFont { + return NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular) +} + +extension NSView { + var hasDarkAppearance: Bool { + if #available(OSX 10.14, *) { + switch effectiveAppearance.name { + case .darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua, .accessibilityHighContrastVibrantDark: + return true + default: + return false + } + } else { + switch effectiveAppearance.name { + case .vibrantDark: + return true + default: + return false + } + } + } +} + +class StatusItemView: NSView { + // MARK: Private variables + + private let locationView: NSTextField = NSTextField(labelWithString: "Hello") + private let timeView: NSTextField = NSTextField(labelWithString: "Mon 19:14 PM") + private var operationsObject: TimezoneDataOperations { + return TimezoneDataOperations(with: dataObject) + } + + private var timeAttributes: [NSAttributedString.Key: AnyObject] { + let textColor = hasDarkAppearance ? NSColor.white : NSColor.black + + let attributes = [ + NSAttributedString.Key.font: compactModeTimeFont, + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.backgroundColor: NSColor.clear, + NSAttributedString.Key.paragraphStyle: defaultParagraphStyle, + ] + return attributes + } + + private var textFontAttributes: [NSAttributedString.Key: Any] { + let textColor = hasDarkAppearance ? NSColor.white : NSColor.black + + let textFontAttributes = [ + NSAttributedString.Key.font: NSFont.boldSystemFont(ofSize: 10), + NSAttributedString.Key.foregroundColor: textColor, + NSAttributedString.Key.backgroundColor: NSColor.clear, + NSAttributedString.Key.paragraphStyle: defaultParagraphStyle, + ] + return textFontAttributes + } + + // MARK: Public + + var dataObject: TimezoneData! { + didSet { + initialSetup() + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + [timeView, locationView].forEach { + $0.wantsLayer = true + $0.applyDefaultStyle() + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + + timeView.disableWrapping() + + NSLayoutConstraint.activate([ + locationView.leadingAnchor.constraint(equalTo: leadingAnchor), + locationView.trailingAnchor.constraint(equalTo: trailingAnchor), + locationView.topAnchor.constraint(equalTo: topAnchor, constant: 7), + locationView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 0.35), + ]) + + NSLayoutConstraint.activate([ + timeView.leadingAnchor.constraint(equalTo: leadingAnchor), + timeView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0), + timeView.topAnchor.constraint(equalTo: locationView.bottomAnchor), + timeView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(OSX 10.14, *) + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateTimeInMenubar() + } + + func updateTimeInMenubar() { + locationView.attributedStringValue = NSAttributedString(string: operationsObject.compactMenuTitle(), attributes: textFontAttributes) + timeView.attributedStringValue = NSAttributedString(string: operationsObject.compactMenuSubtitle(), attributes: timeAttributes) + } + + private func initialSetup() { + locationView.attributedStringValue = NSAttributedString(string: operationsObject.compactMenuTitle(), attributes: textFontAttributes) + timeView.attributedStringValue = NSAttributedString(string: operationsObject.compactMenuSubtitle(), attributes: timeAttributes) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + guard let mainDelegate = NSApplication.shared.delegate as? AppDelegate else { + return + } + + mainDelegate.togglePanel(event) + } +}