You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
434 lines
16 KiB
434 lines
16 KiB
// Copyright © 2015 Abhishek Banthia |
|
|
|
import Cocoa |
|
import CoreLoggerKit |
|
import CoreModelKit |
|
|
|
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.button?.toolTip = "Clocker" |
|
(statusItem.button?.cell as? NSButtonCell)?.highlightsBy = NSCell.StyleMask(rawValue: 0) |
|
return statusItem |
|
}() |
|
|
|
private lazy var menubarTitleHandler = MenubarTitleProvider(with: self.store, eventStore: EventCenter.sharedCenter()) |
|
|
|
private var statusContainerView: StatusContainerView? |
|
|
|
private var nsCalendar = Calendar.autoupdatingCurrent |
|
|
|
private lazy var units: Set<Calendar.Component> = Set([.era, .year, .month, .day, .hour, .minute]) |
|
|
|
private var userNotificationsDidChangeNotif: NSObjectProtocol? |
|
|
|
private let store: DataStore |
|
|
|
// Current State might be 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.button?.subviews = [] |
|
statusContainerView = nil |
|
case .standardText: |
|
statusItem.button?.title = UserDefaultKeys.emptyString |
|
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("Status Bar Current State changed: \(currentState)\n") |
|
} |
|
} |
|
|
|
init(with dataStore: DataStore) { |
|
store = dataStore |
|
super.init() |
|
|
|
setupStatusItem() |
|
setupNotificationObservers() |
|
} |
|
|
|
func setupStatusItem() { |
|
// Let's figure out the initial menubar state |
|
var menubarState = MenubarState.icon |
|
|
|
let shouldTextBeDisplayed = store.menubarTimezones()?.isEmpty ?? true |
|
|
|
if !shouldTextBeDisplayed || store.shouldDisplay(.showMeetingInMenubar) { |
|
if store.shouldDisplay(.menubarCompactMode) { |
|
menubarState = .compactText |
|
} else { |
|
menubarState = .standardText |
|
} |
|
} |
|
|
|
// Initial state has been figured out. Time to set it! |
|
currentState = menubarState |
|
|
|
func setSelector() { |
|
statusItem.button?.action = #selector(menubarIconClicked(_:)) |
|
} |
|
|
|
statusItem.button?.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() |
|
} |
|
|
|
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: OperationQueue.main) { _ in |
|
self.menubarTimer?.invalidate() |
|
} |
|
|
|
NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: OperationQueue.main) { _ in |
|
self.setupStatusItem() |
|
} |
|
} |
|
|
|
deinit { |
|
if let userNotifsDidChange = userNotificationsDidChangeNotif { |
|
NotificationCenter.default.removeObserver(userNotifsDidChange) |
|
} |
|
} |
|
|
|
private func constructCompactView(with upcomingEventView: Bool = false) { |
|
statusItem.button?.subviews = [] |
|
statusContainerView = nil |
|
|
|
let menubarTimezones = store.menubarTimezones() ?? [] |
|
if menubarTimezones.isEmpty { |
|
currentState = .icon |
|
return |
|
} |
|
|
|
statusContainerView = StatusContainerView(with: menubarTimezones, |
|
store: store, |
|
showUpcomingEventView: upcomingEventView, |
|
bufferContainerWidth: bufferCalculatedWidth()) |
|
statusContainerView?.wantsLayer = true |
|
statusItem.button?.addSubview(statusContainerView!) |
|
statusItem.button?.frame = statusContainerView!.bounds |
|
|
|
// For OS < 11, we need to fix the sizing (width) on the button's window |
|
// Otherwise, we won't be able to see the menu bar option at all. |
|
if let window = statusItem.button?.window { |
|
let currentFrame = window.frame |
|
let newFrame = NSRect(x: currentFrame.origin.x, |
|
y: currentFrame.origin.y, |
|
width: statusItem.button?.bounds.size.width ?? 0, |
|
height: currentFrame.size.height) |
|
window.setFrame(newFrame, display: true) |
|
} |
|
statusItem.button?.subviews.first?.window?.backgroundColor = NSColor.clear |
|
} |
|
|
|
// 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 store.shouldDisplay(.menubarCompactMode) { |
|
updateCompactMenubar() |
|
} |
|
} |
|
|
|
@objc func setHasActiveIcon(_ value: Bool) { |
|
hasActiveIcon = value |
|
} |
|
|
|
@objc func menubarIconClicked(_ sender: NSStatusBarButton) { |
|
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.refresh() |
|
} |
|
}) |
|
|
|
// 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 = store.menubarTimezones() ?? [] |
|
|
|
let timezonesSupportingSeconds = syncedTimezones.filter { data in |
|
if let timezoneObj = TimezoneData.customObject(from: data) { |
|
return timezoneObj.shouldShowSeconds(store.timezoneFormat()) |
|
} |
|
return false |
|
} |
|
|
|
return timezonesSupportingSeconds.isEmpty == false |
|
} |
|
|
|
private func calculateFireDate() -> Date? { |
|
let shouldDisplaySeconds = shouldDisplaySecondsInMenubar() |
|
let menubarFavourites = store.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() { |
|
let filteredEvents = EventCenter.sharedCenter().filteredEvents |
|
let calendar = EventCenter.sharedCenter().autoupdatingCalendar |
|
let upcomingEvent = menubarTitleHandler.checkForUpcomingEvents(filteredEvents, calendar: calendar) |
|
if upcomingEvent != nil { |
|
// Iterate and see if we're showing the calendar item view |
|
let upcomingEventView = retrieveUpcomingEventStatusView() |
|
// If not, reconstruct Status Container View with another view |
|
if upcomingEventView == nil { |
|
constructCompactView(with: true) |
|
} |
|
} |
|
|
|
if let upcomingEventView = retrieveUpcomingEventStatusView(), upcomingEvent == nil { |
|
upcomingEventView.removeFromSuperview() |
|
constructCompactView() // So that Status Container View reclaims the space |
|
} |
|
// This will internally call `statusItemViewSetNeedsDisplay` on all subviews ensuring all text in the menubar is up-to-date. |
|
statusContainerView?.updateTime() |
|
} |
|
|
|
private func removeUpcomingStatusItemView() { |
|
NSAnimationContext.runAnimationGroup({ context in |
|
context.duration = 0.2 |
|
let upcomingEventView = retrieveUpcomingEventStatusView() |
|
upcomingEventView?.removeFromSuperview() |
|
}) { [weak self] in |
|
if let sSelf = self { |
|
sSelf.constructCompactView() |
|
} |
|
} |
|
} |
|
|
|
func refresh() { |
|
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 |
|
let attributes = [NSAttributedString.Key.font: NSFont.monospacedDigitSystemFont(ofSize: 13.0, weight: NSFont.Weight.regular), |
|
NSAttributedString.Key.baselineOffset: 0.1] as [NSAttributedString.Key: Any] |
|
statusItem.button?.attributedTitle = NSAttributedString(string: title, attributes: attributes) |
|
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 = store.menubarTimezones() ?? [] |
|
|
|
if menubarFavourites.isEmpty, store.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.button?.subviews.isEmpty == false { |
|
statusItem.button?.subviews = [] |
|
} |
|
|
|
if statusItem.button?.image?.name() == NSImage.Name.menubarIcon { |
|
return |
|
} |
|
|
|
statusItem.button?.title = UserDefaultKeys.emptyString |
|
statusItem.button?.image = NSImage(named: .menubarIcon) |
|
statusItem.button?.imagePosition = .imageOnly |
|
statusItem.button?.toolTip = "Clocker" |
|
} |
|
|
|
private func setupForStandardText() { |
|
var menubarText = UserDefaultKeys.emptyString |
|
|
|
if let menubarTitle = menubarTitleHandler.titleForMenubar() { |
|
menubarText = menubarTitle |
|
} else if store.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 |
|
} |
|
|
|
let attributes = [NSAttributedString.Key.font: NSFont.monospacedDigitSystemFont(ofSize: 13.0, weight: NSFont.Weight.regular), |
|
NSAttributedString.Key.baselineOffset: 0.1] as [NSAttributedString.Key: Any] |
|
statusItem.button?.attributedTitle = NSAttributedString(string: menubarText, attributes: attributes) |
|
statusItem.button?.image = nil |
|
statusItem.button?.imagePosition = .imageLeft |
|
} |
|
|
|
private func setupForCompactTextMode() { |
|
// Let's invalidate the previous timer |
|
menubarTimer?.invalidate() |
|
menubarTimer = nil |
|
|
|
let filteredEvents = EventCenter.sharedCenter().filteredEvents |
|
let calendar = EventCenter.sharedCenter().autoupdatingCalendar |
|
let checkForUpcomingEvents = menubarTitleHandler.checkForUpcomingEvents(filteredEvents, calendar: calendar) |
|
constructCompactView(with: checkForUpcomingEvents != nil) |
|
updateMenubar() |
|
} |
|
|
|
private func retrieveUpcomingEventStatusView() -> NSView? { |
|
let upcomingEventView = statusContainerView?.subviews.first(where: { statusItemView in |
|
if let upcomingEventView = statusItemView as? StatusItemViewConforming { |
|
return upcomingEventView.statusItemViewIdentifier() == "upcoming_event_view" |
|
} |
|
return false |
|
}) |
|
return upcomingEventView |
|
} |
|
|
|
private func bufferCalculatedWidth() -> Int { |
|
var totalWidth = 55 |
|
|
|
if store.shouldShowDayInMenubar() { |
|
totalWidth += 12 |
|
} |
|
|
|
if store.isBufferRequiredForTwelveHourFormats() { |
|
totalWidth += 20 |
|
} |
|
|
|
if store.shouldShowDateInMenubar() { |
|
totalWidth += 20 |
|
} |
|
|
|
if store.shouldDisplay(.showMeetingInMenubar) { |
|
totalWidth += 100 |
|
} |
|
|
|
return totalWidth |
|
} |
|
}
|
|
|